Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
27.61% covered (danger)
27.61%
37 / 134
46.67% covered (danger)
46.67%
7 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
27.61% covered (danger)
27.61%
37 / 134
46.67% covered (danger)
46.67%
7 / 15
615.94
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
 getInstance
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
2
 getActiveCaptchas
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 unsetInstanceForTests
n/a
0 / 0
n/a
0 / 0
2
 onEditFilterMergedContent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 onPageSaveComplete
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getCaptchaTriggerActionFromTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 onEditPageBeforeEditButtons
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 onEditPage__showEditForm_fields
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 onEmailUserForm
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onEmailUser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onAPIGetAllowedParams
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 onAuthChangeFormFields
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 confirmEditSetup
n/a
0 / 0
n/a
0 / 0
3
 onTitleReadWhitelist
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 onFancyCaptchaSetup
n/a
0 / 0
n/a
0 / 0
2
 onAlternateEditPreview
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
42
 onResourceLoaderRegisterModules
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace MediaWiki\Extension\ConfirmEdit;
4
5use BadMethodCallException;
6use MediaWiki\Api\Hook\APIGetAllowedParamsHook;
7use MediaWiki\Auth\AuthenticationRequest;
8use MediaWiki\Content\Content;
9use MediaWiki\Context\IContextSource;
10use MediaWiki\Extension\ConfirmEdit\Auth\CaptchaAuthenticationRequest;
11use MediaWiki\Extension\ConfirmEdit\FancyCaptcha\FancyCaptcha;
12use MediaWiki\Extension\ConfirmEdit\hCaptcha\HCaptcha;
13use MediaWiki\Extension\ConfirmEdit\Hooks\HookRunner;
14use MediaWiki\Extension\ConfirmEdit\QuestyCaptcha\QuestyCaptcha;
15use MediaWiki\Extension\ConfirmEdit\ReCaptchaNoCaptcha\ReCaptchaNoCaptcha;
16use MediaWiki\Extension\ConfirmEdit\SimpleCaptcha\SimpleCaptcha;
17use MediaWiki\Extension\ConfirmEdit\Turnstile\Turnstile;
18use MediaWiki\Hook\AlternateEditPreviewHook;
19use MediaWiki\Hook\EditFilterMergedContentHook;
20use MediaWiki\Hook\EditPage__showEditForm_fieldsHook;
21use MediaWiki\Hook\EditPageBeforeEditButtonsHook;
22use MediaWiki\Hook\EmailUserFormHook;
23use MediaWiki\Hook\EmailUserHook;
24use MediaWiki\Html\Html;
25use MediaWiki\MediaWikiServices;
26use MediaWiki\Permissions\Hook\TitleReadWhitelistHook;
27use MediaWiki\Registration\ExtensionRegistry;
28use MediaWiki\ResourceLoader\Hook\ResourceLoaderRegisterModulesHook;
29use MediaWiki\ResourceLoader\ResourceLoader;
30use MediaWiki\SpecialPage\Hook\AuthChangeFormFieldsHook;
31use MediaWiki\SpecialPage\SpecialPage;
32use MediaWiki\Status\Status;
33use MediaWiki\Storage\Hook\PageSaveCompleteHook;
34use MediaWiki\Title\Title;
35use MediaWiki\User\User;
36use ReflectionClass;
37use Wikimedia\IPUtils;
38use Wikimedia\ObjectCache\WANObjectCache;
39
40class Hooks implements
41    AlternateEditPreviewHook,
42    EditPageBeforeEditButtonsHook,
43    EmailUserFormHook,
44    EmailUserHook,
45    TitleReadWhitelistHook,
46    ResourceLoaderRegisterModulesHook,
47    PageSaveCompleteHook,
48    EditPage__showEditForm_fieldsHook,
49    EditFilterMergedContentHook,
50    APIGetAllowedParamsHook,
51    AuthChangeFormFieldsHook
52{
53
54    /**
55     * @var SimpleCaptcha[][] Captcha instances, where the keys are action => captcha type and the
56     *   values are an instance of that captcha type.
57     */
58    protected static array $instance = [];
59
60    public function __construct(
61        private readonly WANObjectCache $cache,
62    ) {
63    }
64
65    /**
66     * Get the global Captcha instance for a specific action.
67     *
68     * If a specific Captcha is not defined in $wgCaptchaTriggers[$action]['class'],
69     * $wgCaptchaClass will be returned instead.
70     *
71     * @stable to call - May be used by code not visible in codesearch
72     */
73    public static function getInstance( string $action = '' ): SimpleCaptcha {
74        static $map = [
75            'SimpleCaptcha' => SimpleCaptcha::class,
76            'FancyCaptcha' => FancyCaptcha::class,
77            'QuestyCaptcha' => QuestyCaptcha::class,
78            'ReCaptchaNoCaptcha' => ReCaptchaNoCaptcha::class,
79            'HCaptcha' => HCaptcha::class,
80            'Turnstile' => Turnstile::class,
81        ];
82
83        $config = MediaWikiServices::getInstance()->getMainConfig();
84        $captchaTriggers = $config->get( 'CaptchaTriggers' );
85        $defaultCaptchaClass = $config->get( 'CaptchaClass' );
86
87        // Check for the newer style captcha trigger array
88        $class = $captchaTriggers[$action]['class'] ?? $defaultCaptchaClass;
89
90        $hookRunner = new HookRunner(
91            MediaWikiServices::getInstance()->getHookContainer()
92        );
93        // Allow hook implementers to override the class that's about to be cached.
94        $hookRunner->onConfirmEditCaptchaClass( $action, $class );
95
96        if ( !isset( static::$instance[$action][$class] ) ) {
97            // There is not a cached instance, construct a new one based on the mapping
98            /** @var SimpleCaptcha $classInstance */
99            $classInstance = new ( $map[$class] ?? $map[$defaultCaptchaClass] ?? $defaultCaptchaClass );
100            $classInstance->setConfig( $captchaTriggers[$action]['config'] ?? [] );
101            static::$instance[$action][$class] = $classInstance;
102        }
103
104        return static::$instance[$action][$class];
105    }
106
107    /**
108     * Gets a list of all currently active Captcha classes, in the Wikis configuration.
109     *
110     * This includes the default/fallback Captcha of $wgCaptchaClass and any set under
111     * $wgCaptchaTriggers[$action]['class'].
112     */
113    public static function getActiveCaptchas(): array {
114        $instances = [];
115
116        // We can't rely on static::$instance being loaded with all Captcha Types, so make our own list.
117        $defaultCaptcha = self::getInstance();
118        $instances[ ( new ReflectionClass( $defaultCaptcha ) )->getShortName() ] = $defaultCaptcha;
119
120        $captchaTriggers = MediaWikiServices::getInstance()->getMainConfig()->get( 'CaptchaTriggers' );
121        foreach ( $captchaTriggers as $action => $trigger ) {
122            if ( isset( $trigger['class'] ) ) {
123                $class = self::getInstance( $action );
124                $instances[ $trigger['class'] ] = $class;
125            }
126        }
127
128        return $instances;
129    }
130
131    /**
132     * Clears the global Captcha cache for testing
133     *
134     * @codeCoverageIgnore
135     * @internal Only for use in PHPUnit tests.
136     */
137    public static function unsetInstanceForTests(): void {
138        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
139            throw new BadMethodCallException( 'Cannot unset ' . __CLASS__ . ' instance in operation.' );
140        }
141        static::$instance = [];
142    }
143
144    /** @inheritDoc */
145    public function onEditFilterMergedContent( IContextSource $context, Content $content, Status $status,
146        $summary, User $user, $minoredit
147    ) {
148        $simpleCaptcha = self::getInstance( self::getCaptchaTriggerActionFromTitle( $context->getTitle() ) );
149        // Set a flag indicating that ConfirmEdit's implementation of
150        // EditFilterMergedContent ran.
151        // This can be checked by other MediaWiki extensions, e.g. AbuseFilter.
152        $simpleCaptcha->setEditFilterMergedContentHandlerInvoked();
153        return $simpleCaptcha->confirmEditMerged( $context, $content, $status, $summary,
154            $user, $minoredit );
155    }
156
157    /** @inheritDoc */
158    public function onPageSaveComplete(
159        $wikiPage,
160        $user,
161        $summary,
162        $flags,
163        $revisionRecord,
164        $editResult
165    ) {
166        $title = $wikiPage->getTitle();
167        if ( $title->getText() === 'Captcha-ip-whitelist' && $title->getNamespace() === NS_MEDIAWIKI ) {
168            $this->cache->delete( $this->cache->makeKey( 'confirmedit', 'ipbypasslist' ) );
169        }
170
171        return true;
172    }
173
174    /**
175     * Get the relevant CaptchaTriggers action depending on whether the page exists
176     *
177     * @param Title $title
178     * @return string one of "edit" or "create"
179     * @see CaptchaTriggers::EDIT
180     * @see CaptchaTriggers::CREATE
181     */
182    public static function getCaptchaTriggerActionFromTitle( Title $title ): string {
183        return $title->exists() ? CaptchaTriggers::EDIT : CaptchaTriggers::CREATE;
184    }
185
186    /** @inheritDoc */
187    public function onEditPageBeforeEditButtons( $editpage, &$buttons, &$tabindex ) {
188        self::getInstance(
189            self::getCaptchaTriggerActionFromTitle( $editpage->getTitle() )
190        )->editShowCaptcha( $editpage );
191    }
192
193    /** @inheritDoc */
194    public function onEditPage__showEditForm_fields( $editor, $out ) {
195        self::getInstance(
196            self::getCaptchaTriggerActionFromTitle( $out->getTitle() )
197        )->showEditFormFields( $editor, $out );
198    }
199
200    /** @inheritDoc */
201    public function onEmailUserForm( &$form ) {
202        return self::getInstance( CaptchaTriggers::SENDEMAIL )->injectEmailUser( $form );
203    }
204
205    /** @inheritDoc */
206    public function onEmailUser( &$to, &$from, &$subject, &$text, &$error ) {
207        return self::getInstance( CaptchaTriggers::SENDEMAIL )->confirmEmailUser( $from, $to, $subject, $text, $error );
208    }
209
210    /** @inheritDoc */
211    public function onAPIGetAllowedParams( $module, &$params, $flags ) {
212        // To quote Happy-melon from 32102375f80e72c8c4359abbeff66a75da463efa...
213        // > Asking for captchas in the API is really silly
214
215        // Create a merged array of API parameters based on active captcha types.
216        // This may result in clashes/overwriting if multiple Captcha use the same parameter names,
217        // but there's not a lot we can do about that...
218        foreach ( self::getActiveCaptchas() as $instance ) {
219            /** @var SimpleCaptcha $instance */
220            $instance->apiGetAllowedParams( $module, $params, $flags );
221        }
222    }
223
224    /** @inheritDoc */
225    public function onAuthChangeFormFields(
226        $requests, $fieldInfo, &$formDescriptor, $action
227    ) {
228        /** @var CaptchaAuthenticationRequest $req */
229        $req = AuthenticationRequest::getRequestByClass(
230            $requests,
231            CaptchaAuthenticationRequest::class,
232            true
233        );
234        if ( !$req ) {
235            return;
236        }
237
238        self::getInstance( $req->getAction() )
239            ->onAuthChangeFormFields( $requests, $fieldInfo, $formDescriptor, $action );
240    }
241
242    /** @codeCoverageIgnore */
243    public static function confirmEditSetup(): void {
244        global $wgCaptchaTriggers;
245
246        // There is no need to run (core) tests with enabled ConfirmEdit - bug T44145
247        if ( defined( 'MW_PHPUNIT_TEST' ) || defined( 'MW_QUIBBLE_CI' ) ) {
248            $wgCaptchaTriggers = array_fill_keys( array_keys( $wgCaptchaTriggers ), false );
249        }
250    }
251
252    /** @inheritDoc */
253    public function onTitleReadWhitelist( $title, $user, &$whitelisted ) {
254        $image = SpecialPage::getTitleFor( 'Captcha', 'image' );
255        $help = SpecialPage::getTitleFor( 'Captcha', 'help' );
256        if ( $title->equals( $image ) || $title->equals( $help ) ) {
257            $whitelisted = true;
258        }
259    }
260
261    /**
262     * Callback for extension.json of FancyCaptcha to set a default captcha directory,
263     * which depends on wgUploadDirectory
264     *
265     * @codeCoverageIgnore
266     */
267    public static function onFancyCaptchaSetup(): void {
268        global $wgCaptchaDirectory, $wgUploadDirectory;
269        if ( !$wgCaptchaDirectory ) {
270            $wgCaptchaDirectory = "$wgUploadDirectory/captcha";
271        }
272    }
273
274    /** @inheritDoc */
275    public function onAlternateEditPreview( $editPage, &$content, &$previewHTML,
276        &$parserOutput
277    ) {
278        $title = $editPage->getTitle();
279        $exceptionTitle = Title::makeTitle( NS_MEDIAWIKI, 'Captcha-ip-whitelist' );
280
281        if ( !$title->equals( $exceptionTitle ) ) {
282            return true;
283        }
284
285        $ctx = $editPage->getArticle()->getContext();
286        $out = $ctx->getOutput();
287        $lang = $ctx->getLanguage();
288
289        $lines = explode( "\n", $content->getNativeData() );
290        $previewHTML .= Html::warningBox(
291                $ctx->msg( 'confirmedit-preview-description' )->parse()
292            ) .
293            Html::openElement(
294                'table',
295                [ 'class' => 'wikitable sortable' ]
296            ) .
297            Html::openElement( 'thead' ) .
298            Html::element( 'th', [], $ctx->msg( 'confirmedit-preview-line' )->text() ) .
299            Html::element( 'th', [], $ctx->msg( 'confirmedit-preview-content' )->text() ) .
300            Html::element( 'th', [], $ctx->msg( 'confirmedit-preview-validity' )->text() ) .
301            Html::closeElement( 'thead' );
302
303        foreach ( $lines as $count => $line ) {
304            $ip = trim( $line );
305            if ( $ip === '' || strpos( $ip, '#' ) !== false ) {
306                continue;
307            }
308            if ( IPUtils::isIPAddress( $ip ) ) {
309                $validity = $ctx->msg( 'confirmedit-preview-valid' )->escaped();
310                $css = 'valid';
311            } else {
312                $validity = $ctx->msg( 'confirmedit-preview-invalid' )->escaped();
313                $css = 'notvalid';
314            }
315            $previewHTML .= Html::openElement( 'tr' ) .
316                Html::element(
317                    'td',
318                    [],
319                    $lang->formatNum( $count + 1 )
320                ) .
321                Html::element(
322                    'td',
323                    [],
324                    // IPv6 max length: 8 groups * 4 digits + 7 delimiter = 39
325                    // + 11 chars for safety
326                    $lang->truncateForVisual( $ip, 50 )
327                ) .
328                Html::rawElement(
329                    'td',
330                    // possible values:
331                    // mw-confirmedit-ip-valid
332                    // mw-confirmedit-ip-notvalid
333                    [ 'class' => 'mw-confirmedit-ip-' . $css ],
334                    $validity
335                ) .
336                Html::closeElement( 'tr' );
337        }
338        $previewHTML .= Html::closeElement( 'table' );
339        $out->addModuleStyles( 'ext.confirmEdit.editPreview.ipwhitelist.styles' );
340
341        return false;
342    }
343
344    /** @inheritDoc */
345    public function onResourceLoaderRegisterModules( ResourceLoader $rl ): void {
346        $extensionRegistry = ExtensionRegistry::getInstance();
347        $messages = [
348            'colon-separator',
349            'captcha-edit',
350            'captcha-label'
351        ];
352
353        if ( $extensionRegistry->isLoaded( 'QuestyCaptcha' ) ) {
354            $messages[] = 'questycaptcha-edit';
355        }
356
357        if ( $extensionRegistry->isLoaded( 'FancyCaptcha' ) ) {
358            $messages[] = 'fancycaptcha-edit';
359            $messages[] = 'fancycaptcha-reload-text';
360            $messages[] = 'fancycaptcha-imgcaptcha-ph';
361        }
362
363        $rl->register( [
364            'ext.confirmEdit.CaptchaInputWidget' => [
365                'localBasePath' => dirname( __DIR__ ),
366                'remoteExtPath' => 'ConfirmEdit',
367                'scripts' => 'resources/libs/ext.confirmEdit.CaptchaInputWidget.js',
368                'styles' => 'resources/libs/ext.confirmEdit.CaptchaInputWidget.less',
369                'messages' => $messages,
370                'dependencies' => 'oojs-ui-core',
371            ]
372        ] );
373    }
374
375}