*/ private const PROVIDERS = array( // Turnstile is a service the site owner signs up for and switches on; it is listed under // External services in readme.txt. Its script cannot be bundled, it must come from Cloudflare. 'turnstile' => array( 'name' => 'Cloudflare Turnstile', 'script' => 'https://challenges.cloudflare.com/turnstile/v0/api.js', // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent 'verify' => 'https://challenges.cloudflare.com/turnstile/v0/siteverify', // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent 'field' => 'cf-turnstile-response', 'class' => 'cf-turnstile', ), 'hcaptcha' => array( 'name' => 'hCaptcha', 'script' => 'https://js.hcaptcha.com/1/api.js', 'verify' => 'https://api.hcaptcha.com/siteverify', 'field' => 'h-captcha-response', 'class' => 'h-captcha', ), ); /** * Saved settings. * * @return array{provider: string, site_key: string, secret: string} */ public static function config(): array { $saved = get_option( self::OPTION, array() ); return self::sanitize( is_array( $saved ) ? $saved : array() ); } /** * Cleans settings. An unknown provider or a key with unexpected characters is dropped. * * @param array $values Raw values. * @return array{provider: string, site_key: string, secret: string} */ public static function sanitize( array $values ): array { $provider = (string) ( $values['provider'] ?? '' ); $key = static function ( mixed $value ): string { $value = is_scalar( $value ) ? trim( (string) $value ) : ''; return 1 === preg_match( '/^[A-Za-z0-9_.\-]{8,200}$/', $value ) ? $value : ''; }; return array( 'provider' => isset( self::PROVIDERS[ $provider ] ) ? $provider : '', 'site_key' => $key( $values['site_key'] ?? '' ), 'secret' => $key( $values['secret'] ?? '' ), ); } /** * Saves settings. An empty secret keeps the saved one, so the admin screen never has to show it. * * @param array $values Raw values. * @return array{provider: string, site_key: string, secret: string} */ public static function save( array $values ): array { $clean = self::sanitize( $values ); if ( '' === $clean['secret'] && self::config()['provider'] === $clean['provider'] ) { $clean['secret'] = self::config()['secret']; } if ( '' === $clean['provider'] ) { $clean = self::sanitize( array() ); } update_option( self::OPTION, $clean, false ); return $clean; } /** * Settings for the admin screen, without the secret. * * @return array{provider: string, site_key: string, has_secret: bool, active: bool} */ public static function public_config(): array { $config = self::config(); return array( 'provider' => $config['provider'], 'site_key' => $config['site_key'], 'has_secret' => '' !== $config['secret'], 'active' => self::active(), ); } /** * Whether a provider and both keys are set. */ public static function active(): bool { $config = self::config(); return '' !== $config['provider'] && '' !== $config['site_key'] && '' !== $config['secret']; } /** * Name of the chosen provider, empty when none is chosen. */ public static function provider_name(): string { $provider = self::config()['provider']; return '' !== $provider ? self::PROVIDERS[ $provider ]['name'] : ''; } /** * The widget for a form, and the provider's script. Empty when the check is not set up. */ public static function markup(): string { if ( ! self::active() ) { return ''; } $config = self::config(); $provider = self::PROVIDERS[ $config['provider'] ]; // The provider serves its own current version, so no version is added to the address. wp_enqueue_script( 'fxd-captcha', $provider['script'], array(), null, array( 'strategy' => 'defer' ) ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters return '
'; } /** * Checks a submission with the provider. * * @param array $post Submitted data. * @param Http $http Outbound gateway. * @return string One of the class constants. */ public static function verify( array $post, Http $http ): string { $config = self::config(); $provider = self::PROVIDERS[ $config['provider'] ] ?? null; if ( null === $provider ) { return self::OK; } $token = is_scalar( $post[ $provider['field'] ] ?? null ) ? trim( (string) $post[ $provider['field'] ] ) : ''; if ( '' === $token || strlen( $token ) > 4096 ) { return self::FAILED; } $response = $http->request( 'POST', $provider['verify'], array( 'body' => array( 'secret' => $config['secret'], 'response' => $token, ), ) ); if ( 200 !== $response['status'] ) { return self::UNREACHABLE; } $answer = json_decode( $response['body'], true ); if ( ! is_array( $answer ) ) { return self::UNREACHABLE; } return true === ( $answer['success'] ?? false ) ? self::OK : self::FAILED; } }