initial
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
/**
|
||||
* Turnstile CAPTCHA verification.
|
||||
*
|
||||
* Renders and verifies Cloudflare Turnstile tokens for form submissions.
|
||||
* Hooks into form render and verification filters — not actions.
|
||||
*
|
||||
* @package GenerateBlocksPro
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turnstile CAPTCHA class.
|
||||
*/
|
||||
class GenerateBlocks_Pro_Form_Turnstile {
|
||||
|
||||
/**
|
||||
* Cloudflare Turnstile verification endpoint.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
|
||||
/**
|
||||
* Initialize Turnstile verification.
|
||||
*/
|
||||
public static function init() {
|
||||
GenerateBlocks_Pro_Form_Integration_Registry::register(
|
||||
[
|
||||
'id' => 'turnstile',
|
||||
'label' => __( 'Cloudflare Turnstile', 'generateblocks-pro' ),
|
||||
'type' => 'spam',
|
||||
'connection' => [
|
||||
'type' => 'api_key',
|
||||
'fields' => [
|
||||
[
|
||||
'key' => 'site_key',
|
||||
'label' => __( 'Site Key', 'generateblocks-pro' ),
|
||||
'type' => 'text',
|
||||
'help' => __( 'The public key rendered in your form. Find it in your Cloudflare dashboard under Turnstile.', 'generateblocks-pro' ),
|
||||
'required' => true,
|
||||
'test_required' => false,
|
||||
'define' => 'GENERATEBLOCKS_PRO_TURNSTILE_SITE_KEY',
|
||||
'public' => true,
|
||||
],
|
||||
[
|
||||
'key' => 'api_key',
|
||||
'label' => __( 'Secret Key', 'generateblocks-pro' ),
|
||||
'type' => 'password',
|
||||
'help' => __( 'The secret key used to verify submissions server-side.', 'generateblocks-pro' ),
|
||||
'required' => true,
|
||||
'define' => 'GENERATEBLOCKS_PRO_TURNSTILE_SECRET_KEY',
|
||||
],
|
||||
],
|
||||
'test_callback' => function ( $settings ) {
|
||||
return self::test_connection( $settings['api_key'] ?? '' );
|
||||
},
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
add_filter( 'generateblocks_form_verify_submission', [ __CLASS__, 'verify_submission' ], 10, 4 );
|
||||
add_filter( 'generateblocks_form_render_footer_html', [ __CLASS__, 'render_widget' ], 10, 6 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Turnstile widget inside the Form block.
|
||||
*
|
||||
* Hooked to the form render footer filter so all Turnstile concerns —
|
||||
* render, script enqueue, server-side verification — live in one class.
|
||||
*
|
||||
* @param string $html Accumulated HTML from prior callbacks.
|
||||
* @param int $form_id Form post ID. Unused; required by filter signature.
|
||||
* @param int $host_post_id Host page ID. Unused; required by filter signature.
|
||||
* @param array $config Form config from meta.
|
||||
* @param string $context 'public' or 'editor'.
|
||||
* @param int $instance Per-render instance number. Unused; required by filter signature.
|
||||
* @return string
|
||||
*/
|
||||
public static function render_widget( $html, $form_id, $host_post_id, $config, $context, $instance ) {
|
||||
unset( $form_id, $host_post_id, $instance );
|
||||
|
||||
if ( 'public' !== $context ) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
if ( empty( $config['useTurnstile'] ) ) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$site_key = self::get_site_key();
|
||||
|
||||
if ( '' === $site_key ) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
wp_enqueue_script(
|
||||
'cf-turnstile',
|
||||
'https://challenges.cloudflare.com/turnstile/v0/api.js',
|
||||
[],
|
||||
GENERATEBLOCKS_PRO_VERSION,
|
||||
[ 'strategy' => 'defer' ]
|
||||
);
|
||||
|
||||
return $html . sprintf(
|
||||
'<div class="cf-turnstile" data-sitekey="%s"></div>',
|
||||
esc_attr( $site_key )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the Turnstile token on form submission.
|
||||
*
|
||||
* @param null|WP_Error $pre_result Default null. Return WP_Error to reject.
|
||||
* @param int $form_id The form post ID.
|
||||
* @param array $raw_fields All submitted fields.
|
||||
* @param array $context Request context.
|
||||
* @return null|WP_Error
|
||||
*/
|
||||
public static function verify_submission( $pre_result, $form_id, $raw_fields, $context ) {
|
||||
// Don't override an existing rejection.
|
||||
if ( is_wp_error( $pre_result ) ) {
|
||||
return $pre_result;
|
||||
}
|
||||
|
||||
$secret_key = self::get_secret_key();
|
||||
|
||||
// No secret key configured — Turnstile is not set up, pass through.
|
||||
if ( empty( $secret_key ) ) {
|
||||
return $pre_result;
|
||||
}
|
||||
|
||||
// No turnstile_token in context — the client didn't send one. Pass
|
||||
// through here; the processor's server-side useTurnstile check
|
||||
// (which runs after this filter) will reject if the form requires it.
|
||||
if ( ! array_key_exists( 'turnstile_token', $context ) ) {
|
||||
return $pre_result;
|
||||
}
|
||||
|
||||
$token = $context['turnstile_token'];
|
||||
|
||||
// Form has Turnstile enabled but no token was provided (bot or JS error).
|
||||
if ( empty( $token ) ) {
|
||||
return new WP_Error( 'turnstile_missing', 'Turnstile verification required.' );
|
||||
}
|
||||
|
||||
$response = wp_safe_remote_post(
|
||||
self::VERIFY_URL,
|
||||
[
|
||||
'timeout' => 10,
|
||||
'body' => [
|
||||
'secret' => $secret_key,
|
||||
'response' => $token,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Filter whether Turnstile should fail open when Cloudflare is unreachable.
|
||||
*
|
||||
* Default: false (fail closed). If Cloudflare is down, submissions are
|
||||
* rejected — a form configured with CAPTCHA should keep its CAPTCHA
|
||||
* protection rather than silently disappearing. Sites that prefer to
|
||||
* accept submissions during provider outages can return true.
|
||||
*
|
||||
* @since 2.6.0
|
||||
* @param bool $fail_open Whether to fail open. Default false.
|
||||
* @param int $form_id The form post ID.
|
||||
*/
|
||||
$fail_open = (bool) apply_filters( 'generateblocks_form_turnstile_fail_open', false, $form_id );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log( 'GenerateBlocks Form: Turnstile verification request failed: ' . $response->get_error_message() );
|
||||
}
|
||||
|
||||
if ( $fail_open ) {
|
||||
return $pre_result;
|
||||
}
|
||||
|
||||
return new WP_Error( 'turnstile_unreachable', 'Turnstile verification unavailable.' );
|
||||
}
|
||||
|
||||
$status = wp_remote_retrieve_response_code( $response );
|
||||
|
||||
if ( $status < 200 || $status >= 300 ) {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log( 'GenerateBlocks Form: Turnstile returned HTTP ' . $status );
|
||||
}
|
||||
|
||||
if ( $fail_open ) {
|
||||
return $pre_result;
|
||||
}
|
||||
|
||||
return new WP_Error( 'turnstile_unreachable', 'Turnstile verification unavailable.' );
|
||||
}
|
||||
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
if ( ! is_array( $body ) || empty( $body['success'] ) ) {
|
||||
return new WP_Error( 'turnstile_failed', 'Turnstile verification failed.' );
|
||||
}
|
||||
|
||||
return $pre_result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Turnstile site key.
|
||||
*
|
||||
* Priority: wp-config.php define → database option.
|
||||
*
|
||||
* @return string The site key, or empty string if not configured.
|
||||
*/
|
||||
public static function get_site_key() {
|
||||
return GenerateBlocks_Pro_Form_Integration_Rest::get_site_key_for( 'turnstile' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Turnstile secret key.
|
||||
*
|
||||
* Uses the standard integration key lookup (define → registry → db).
|
||||
*
|
||||
* @return string The secret key, or empty string if not configured.
|
||||
*/
|
||||
public static function get_secret_key() {
|
||||
return GenerateBlocks_Pro_Form_Integration_Rest::get_api_key_for( 'turnstile' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the Turnstile secret key.
|
||||
*
|
||||
* Sends a verification request with an empty token. Cloudflare will return
|
||||
* "invalid-input-secret" if the key is wrong, or other error codes if the
|
||||
* key is valid but the token is (expectedly) invalid.
|
||||
*
|
||||
* @param string $secret_key The secret key to test.
|
||||
* @return true|WP_Error
|
||||
*/
|
||||
public static function test_connection( $secret_key ) {
|
||||
$response = wp_safe_remote_post(
|
||||
self::VERIFY_URL,
|
||||
[
|
||||
'timeout' => 10,
|
||||
'body' => [
|
||||
'secret' => $secret_key,
|
||||
'response' => '',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return new WP_Error( 'connection_failed', 'Could not connect to Cloudflare.' );
|
||||
}
|
||||
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
// If the secret key is invalid, Cloudflare includes "invalid-input-secret"
|
||||
// in the error-codes array. Any other error means the key is valid.
|
||||
$error_codes = $body['error-codes'] ?? [];
|
||||
|
||||
if ( in_array( 'invalid-input-secret', $error_codes, true ) ) {
|
||||
return new WP_Error( 'invalid_key', 'Invalid Turnstile secret key.' );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user