Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 107
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
0.00% covered (danger)
0.00%
0 / 107
0.00% covered (danger)
0.00%
0 / 7
552
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 onEditFilterMergedContent
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
42
 onParserOutputStashForEdit
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 onUserCanSendEmail
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 onEditFilter
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
20
 onPageSaveComplete
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 onUploadVerifyUpload
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace MediaWiki\Extension\SpamBlacklist;
4
5use ApiMessage;
6use Content;
7use IContextSource;
8use LogicException;
9use MediaWiki\Content\IContentHandlerFactory;
10use MediaWiki\Content\Renderer\ContentRenderer;
11use MediaWiki\EditPage\EditPage;
12use MediaWiki\ExternalLinks\LinkFilter;
13use MediaWiki\Hook\EditFilterHook;
14use MediaWiki\Hook\EditFilterMergedContentHook;
15use MediaWiki\Hook\UploadVerifyUploadHook;
16use MediaWiki\Html\Html;
17use MediaWiki\Parser\ParserOutput;
18use MediaWiki\Permissions\PermissionManager;
19use MediaWiki\Revision\RevisionRecord;
20use MediaWiki\Status\Status;
21use MediaWiki\Storage\EditResult;
22use MediaWiki\Storage\Hook\PageSaveCompleteHook;
23use MediaWiki\Storage\Hook\ParserOutputStashForEditHook;
24use MediaWiki\Storage\PageEditStash;
25use MediaWiki\User\Hook\UserCanSendEmailHook;
26use MediaWiki\User\User;
27use MediaWiki\User\UserIdentity;
28use Message;
29use MessageSpecifier;
30use ParserOptions;
31use UploadBase;
32use Wikimedia\Assert\PreconditionException;
33use WikiPage;
34
35/**
36 * Hooks for the spam blacklist extension
37 */
38class Hooks implements
39    EditFilterHook,
40    EditFilterMergedContentHook,
41    UploadVerifyUploadHook,
42    PageSaveCompleteHook,
43    ParserOutputStashForEditHook,
44    UserCanSendEmailHook
45{
46
47    /** @var PermissionManager */
48    private $permissionManager;
49
50    /** @var PageEditStash */
51    private $pageEditStash;
52
53    /** @var ContentRenderer */
54    private $contentRenderer;
55
56    /** @var IContentHandlerFactory */
57    private $contentHandlerFactory;
58
59    /**
60     * @param PermissionManager $permissionManager
61     * @param PageEditStash $pageEditStash
62     * @param ContentRenderer $contentRenderer
63     * @param IContentHandlerFactory $contentHandlerFactory
64     */
65    public function __construct(
66        PermissionManager $permissionManager,
67        PageEditStash $pageEditStash,
68        ContentRenderer $contentRenderer,
69        IContentHandlerFactory $contentHandlerFactory
70    ) {
71        $this->permissionManager = $permissionManager;
72        $this->pageEditStash = $pageEditStash;
73        $this->contentRenderer = $contentRenderer;
74        $this->contentHandlerFactory = $contentHandlerFactory;
75    }
76
77    /**
78     * Hook function for EditFilterMergedContent
79     *
80     * @param IContextSource $context
81     * @param Content $content
82     * @param Status $status
83     * @param string $summary
84     * @param User $user
85     * @param bool $minoredit
86     *
87     * @return bool
88     */
89    public function onEditFilterMergedContent(
90        IContextSource $context,
91        Content $content,
92        Status $status,
93        $summary,
94        User $user,
95        $minoredit
96    ) {
97        if ( $this->permissionManager->userHasRight( $user, 'sboverride' ) ) {
98            return true;
99        }
100
101        $title = $context->getTitle();
102        try {
103            // Try getting the update directly
104            $updater = $context->getWikiPage()->getCurrentUpdate();
105            $pout = $updater->getParserOutputForMetaData();
106        } catch ( PreconditionException | LogicException $exception ) {
107            $stashedEdit = $this->pageEditStash->checkCache(
108                $title,
109                $content,
110                $user
111            );
112            if ( $stashedEdit ) {
113                // Try getting the value from edit stash
114                /** @var ParserOutput $output */
115                $pout = $stashedEdit->output;
116            } else {
117                // Last resort, parse the page.
118                $pout = $this->contentRenderer->getParserOutput(
119                    $content,
120                    $title,
121                    null,
122                    null,
123                    false
124                );
125            }
126        }
127        $links = LinkFilter::getIndexedUrlsNonReversed( array_keys( $pout->getExternalLinks() ) );
128        // HACK: treat the edit summary as a link if it contains anything
129        // that looks like it could be a URL or e-mail address.
130        if ( preg_match( '/\S(\.[^\s\d]{2,}|[\/@]\S)/', $summary ) ) {
131            $links[] = $summary;
132        }
133
134        $spamObj = BaseBlacklist::getSpamBlacklist();
135        $matches = $spamObj->filter( $links, $title, $user );
136
137        if ( $matches !== false ) {
138            $error = new ApiMessage(
139                wfMessage( 'spam-blacklisted-link', Message::listParam( $matches ) ),
140                'spamblacklist',
141                [
142                    'spamblacklist' => [ 'matches' => $matches ],
143                ]
144            );
145            $status->fatal( $error );
146            return false;
147        }
148
149        return true;
150    }
151
152    /**
153     * @param WikiPage $page
154     * @param Content $content
155     * @param ParserOutput $output
156     * @param string $summary
157     * @param User $user
158     */
159    public function onParserOutputStashForEdit(
160        $page,
161        $content,
162        $output,
163        $summary,
164        $user
165    ) {
166        $links = LinkFilter::getIndexedUrlsNonReversed( array_keys( $output->getExternalLinks() ) );
167        $spamObj = BaseBlacklist::getSpamBlacklist();
168        $spamObj->warmCachesForFilter( $page->getTitle(), $links, $user );
169    }
170
171    /**
172     * Verify that the user can send emails
173     *
174     * @param User $user
175     * @param array &$hookErr
176     * @return bool
177     */
178    public function onUserCanSendEmail( $user, &$hookErr ) {
179        if ( $this->permissionManager->userHasRight( $user, 'sboverride' ) ) {
180            return true;
181        }
182        $blacklist = BaseBlacklist::getEmailBlacklist();
183        if ( $blacklist->checkUser( $user ) ) {
184            return true;
185        }
186
187        $hookErr = [ 'spam-blacklisted-email', 'spam-blacklisted-email-text', null ];
188
189        // No other hook handler should run
190        return false;
191    }
192
193    /**
194     * Hook function for EditFilter
195     * Confirm that a local blacklist page being saved is valid,
196     * and toss back a warning to the user if it isn't.
197     *
198     * @param EditPage $editPage
199     * @param string $text
200     * @param string $section
201     * @param string &$hookError
202     * @param string $summary
203     */
204    public function onEditFilter( $editPage, $text, $section, &$hookError, $summary ) {
205        $title = $editPage->getTitle();
206        $thisPageName = $title->getPrefixedDBkey();
207
208        if ( !BaseBlacklist::isLocalSource( $title ) ) {
209            wfDebugLog( 'SpamBlacklist',
210                "Spam blacklist validator: [[$thisPageName]] not a local blacklist\n"
211            );
212            return;
213        }
214
215        $type = BaseBlacklist::getTypeFromTitle( $title );
216        if ( $type === false ) {
217            return;
218        }
219
220        $lines = explode( "\n", $text );
221
222        $badLines = SpamRegexBatch::getBadLines( $lines, BaseBlacklist::getInstance( $type ) );
223        if ( $badLines ) {
224            wfDebugLog( 'SpamBlacklist',
225                "Spam blacklist validator: [[$thisPageName]] given invalid input lines: " .
226                    implode( ', ', $badLines ) . "\n"
227            );
228
229            $badList = "*<code>" .
230                implode( "</code>\n*<code>",
231                    array_map( 'wfEscapeWikiText', $badLines ) ) .
232                "</code>\n";
233            $hookError =
234                Html::errorBox(
235                    wfMessage( 'spam-invalid-lines' )->numParams( $badLines )->text() . "<br />" .
236                    $badList
237                    ) .
238                    "\n<br clear='all' />\n";
239        } else {
240            wfDebugLog( 'SpamBlacklist',
241                "Spam blacklist validator: [[$thisPageName]] ok or empty blacklist\n"
242            );
243        }
244    }
245
246    /**
247     * Hook function for PageSaveComplete
248     * Clear local spam blacklist caches on page save.
249     *
250     * @param WikiPage $wikiPage
251     * @param UserIdentity $userIdentity
252     * @param string $summary
253     * @param int $flags
254     * @param RevisionRecord $revisionRecord
255     * @param EditResult $editResult
256     */
257    public function onPageSaveComplete(
258        $wikiPage,
259        $userIdentity,
260        $summary,
261        $flags,
262        $revisionRecord,
263        $editResult
264    ) {
265        if ( !BaseBlacklist::isLocalSource( $wikiPage->getTitle() ) ) {
266            return;
267        }
268
269        // This sucks because every Blacklist needs to be cleared
270        foreach ( BaseBlacklist::getBlacklistTypes() as $type => $class ) {
271            $blacklist = BaseBlacklist::getInstance( $type );
272            $blacklist->clearCache();
273        }
274    }
275
276    /**
277     * @param UploadBase $upload
278     * @param User $user
279     * @param array|null $props
280     * @param string $comment
281     * @param string $pageText
282     * @param array|MessageSpecifier &$error
283     */
284    public function onUploadVerifyUpload(
285        UploadBase $upload,
286        User $user,
287        ?array $props,
288        $comment,
289        $pageText,
290        &$error
291    ) {
292        if ( $this->permissionManager->userHasRight( $user, 'sboverride' ) ) {
293            return;
294        }
295
296        $title = $upload->getTitle();
297
298        // get the link from the not-yet-saved page content.
299        $content = $this->contentHandlerFactory->getContentHandler( $title->getContentModel() )
300            ->unserializeContent( $pageText );
301        $parserOptions = ParserOptions::newFromAnon();
302        $output = $this->contentRenderer->getParserOutput( $content, $title, null, $parserOptions );
303        $links = LinkFilter::getIndexedUrlsNonReversed( array_keys( $output->getExternalLinks() ) );
304
305        // HACK: treat comment as a link if it contains anything
306        // that looks like it could be a URL or e-mail address.
307        if ( preg_match( '/\S(\.[^\s\d]{2,}|[\/@]\S)/', $comment ) ) {
308            $links[] = $comment;
309        }
310        if ( !$links ) {
311            return;
312        }
313
314        $spamObj = BaseBlacklist::getSpamBlacklist();
315        $matches = $spamObj->filter( $links, $title, $user );
316
317        if ( $matches !== false ) {
318            $error = new ApiMessage(
319                wfMessage( 'spam-blacklisted-link', Message::listParam( $matches ) ),
320                'spamblacklist',
321                [
322                    'spamblacklist' => [ 'matches' => $matches ],
323                ]
324            );
325        }
326    }
327}