Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
27.61% |
37 / 134 |
|
46.67% |
7 / 15 |
CRAP | |
0.00% |
0 / 1 |
| Hooks | |
27.61% |
37 / 134 |
|
46.67% |
7 / 15 |
615.94 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getInstance | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
2 | |||
| getActiveCaptchas | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
| unsetInstanceForTests | n/a |
0 / 0 |
n/a |
0 / 0 |
2 | |||||
| onEditFilterMergedContent | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| onPageSaveComplete | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| getCaptchaTriggerActionFromTitle | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| onEditPageBeforeEditButtons | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| onEditPage__showEditForm_fields | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| onEmailUserForm | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| onEmailUser | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| onAPIGetAllowedParams | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| onAuthChangeFormFields | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
| confirmEditSetup | n/a |
0 / 0 |
n/a |
0 / 0 |
3 | |||||
| onTitleReadWhitelist | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
| onFancyCaptchaSetup | n/a |
0 / 0 |
n/a |
0 / 0 |
2 | |||||
| onAlternateEditPreview | |
0.00% |
0 / 49 |
|
0.00% |
0 / 1 |
42 | |||
| onResourceLoaderRegisterModules | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
12 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace MediaWiki\Extension\ConfirmEdit; |
| 4 | |
| 5 | use BadMethodCallException; |
| 6 | use MediaWiki\Api\Hook\APIGetAllowedParamsHook; |
| 7 | use MediaWiki\Auth\AuthenticationRequest; |
| 8 | use MediaWiki\Content\Content; |
| 9 | use MediaWiki\Context\IContextSource; |
| 10 | use MediaWiki\Extension\ConfirmEdit\Auth\CaptchaAuthenticationRequest; |
| 11 | use MediaWiki\Extension\ConfirmEdit\FancyCaptcha\FancyCaptcha; |
| 12 | use MediaWiki\Extension\ConfirmEdit\hCaptcha\HCaptcha; |
| 13 | use MediaWiki\Extension\ConfirmEdit\Hooks\HookRunner; |
| 14 | use MediaWiki\Extension\ConfirmEdit\QuestyCaptcha\QuestyCaptcha; |
| 15 | use MediaWiki\Extension\ConfirmEdit\ReCaptchaNoCaptcha\ReCaptchaNoCaptcha; |
| 16 | use MediaWiki\Extension\ConfirmEdit\SimpleCaptcha\SimpleCaptcha; |
| 17 | use MediaWiki\Extension\ConfirmEdit\Turnstile\Turnstile; |
| 18 | use MediaWiki\Hook\AlternateEditPreviewHook; |
| 19 | use MediaWiki\Hook\EditFilterMergedContentHook; |
| 20 | use MediaWiki\Hook\EditPage__showEditForm_fieldsHook; |
| 21 | use MediaWiki\Hook\EditPageBeforeEditButtonsHook; |
| 22 | use MediaWiki\Hook\EmailUserFormHook; |
| 23 | use MediaWiki\Hook\EmailUserHook; |
| 24 | use MediaWiki\Html\Html; |
| 25 | use MediaWiki\MediaWikiServices; |
| 26 | use MediaWiki\Permissions\Hook\TitleReadWhitelistHook; |
| 27 | use MediaWiki\Registration\ExtensionRegistry; |
| 28 | use MediaWiki\ResourceLoader\Hook\ResourceLoaderRegisterModulesHook; |
| 29 | use MediaWiki\ResourceLoader\ResourceLoader; |
| 30 | use MediaWiki\SpecialPage\Hook\AuthChangeFormFieldsHook; |
| 31 | use MediaWiki\SpecialPage\SpecialPage; |
| 32 | use MediaWiki\Status\Status; |
| 33 | use MediaWiki\Storage\Hook\PageSaveCompleteHook; |
| 34 | use MediaWiki\Title\Title; |
| 35 | use MediaWiki\User\User; |
| 36 | use ReflectionClass; |
| 37 | use Wikimedia\IPUtils; |
| 38 | use Wikimedia\ObjectCache\WANObjectCache; |
| 39 | |
| 40 | class 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 | } |