Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.58% covered (warning)
89.58%
129 / 144
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
RunVariableGenerator
89.58% covered (warning)
89.58%
129 / 144
70.00% covered (warning)
70.00%
7 / 10
34.23
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
63.89% covered (warning)
63.89%
23 / 36
0.00% covered (danger)
0.00%
0 / 1
9.31
 getAccountCreationVars
100.00% covered (success)
100.00%
25 / 25
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\Upload\UploadBase;
17use MediaWiki\User\User;
18use MediaWiki\User\UserFactory;
19use MediaWiki\Utils\MWFileProps;
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                if ( $oldcontent === null ) {
316                    throw new \UnexpectedValueException( 'Failed to retrieve the old page content' );
317                }
318                $oldtext = $this->textExtractor->contentToString( $oldcontent );
319
320                // Page text is ignored for uploads when the page already exists
321                $text = $oldtext;
322            } else {
323                $oldtext = '';
324                $this->setLastEditAge( null, 'page' );
325            }
326
327            // Load vars for filters to check
328            $this->vars->setVar( 'summary', $summary );
329            $this->vars->setVar( 'old_wikitext', $oldtext );
330            $this->vars->setVar( 'new_wikitext', $text );
331            // TODO: set old_content_model and new_content_model vars, use them
332            $this->addEditVars( $page, $this->user );
333        }
334        $this->addGenericVars();
335        return $this->vars;
336    }
337
338    /**
339     * Get variables for filtering an account creation
340     *
341     * @param User $createdUser This is the user being created, not the creator (which is $this->user)
342     * @param bool $autocreate
343     * @return VariableHolder
344     */
345    public function getAccountCreationVars(
346        User $createdUser,
347        bool $autocreate
348    ): VariableHolder {
349        // generateUserVars records $this->user->getName() which would be the IP for unregistered users
350        if ( $this->user->isRegistered() ) {
351            $this->addUserVars( $this->user );
352        } else {
353            // Set the user_type for IP users, so that filters can distinguish between account
354            // creations from temporary accounts and those from IP addresses.
355            $this->vars->setLazyLoadVar(
356                'user_type',
357                'user-type',
358                [ 'user-identity' => $this->user ]
359            );
360
361            // Extra security layer to make user_unnamed_ip accessible only for temp account creation
362            // See also LazyVariableComputer::compute(), which conditionally exposes the source IP
363            if ( $autocreate && $createdUser->isTemp() ) {
364                $this->vars->setLazyLoadVar(
365                    'user_unnamed_ip',
366                    'user-unnamed-ip',
367                    [ 'user' => $this->user, 'rc' => null ]
368                );
369            }
370        }
371
372        $this->vars->setVar( 'action', $autocreate ? 'autocreateaccount' : 'createaccount' );
373        $this->vars->setVar( 'account_name', $createdUser->getName() );
374        $this->vars->setLazyLoadVar(
375            'account_type',
376            'account-type',
377            [ 'autocreate' => $autocreate, 'createdUser' => $createdUser ]
378        );
379        $this->addGenericVars();
380
381        $this->hookRunner->onAbuseFilterGenerateAccountCreationVars(
382            $this->vars, $this->user, $createdUser, $autocreate, null
383        );
384        return $this->vars;
385    }
386}