Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.68% covered (warning)
89.68%
113 / 126
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
RunVariableGenerator
89.68% covered (warning)
89.68%
113 / 126
70.00% covered (warning)
70.00%
7 / 10
29.92
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
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%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 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%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getDeleteVars
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getUploadVars
66.67% covered (warning)
66.67%
22 / 33
0.00% covered (danger)
0.00%
0 / 1
7.33
 getAccountCreationVars
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\VariableGenerator;
4
5use Content;
6use LogicException;
7use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
8use MediaWiki\Extension\AbuseFilter\TextExtractor;
9use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
10use MediaWiki\Page\WikiPageFactory;
11use MediaWiki\Revision\MutableRevisionRecord;
12use MediaWiki\Revision\RevisionRecord;
13use MediaWiki\Revision\SlotRecord;
14use MediaWiki\Title\Title;
15use MediaWiki\User\User;
16use MediaWiki\User\UserFactory;
17use MimeAnalyzer;
18use MWFileProps;
19use UploadBase;
20use Wikimedia\Assert\PreconditionException;
21use WikiPage;
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     * @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-by-title',
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}