This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,108 @@
<?php
/**
* Boot the optional Forms module.
*
* Nothing in this file is loaded unless Forms is enabled. Keep all Forms
* class loading, hooks, routes, post types, and provider registration behind
* this boundary so disabled Forms stay out of the request lifecycle.
*
* @package GenerateBlocks Pro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/class-form-sanitizer.php';
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/class-form-spam.php';
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/class-form-turnstile.php';
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/class-form-http.php';
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/class-form-action-registry.php';
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/class-form-integration-registry.php';
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/class-form-action-email.php';
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/class-form-action-email-signup.php';
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/integrations/builtin-loader.php';
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/class-form-action-webhook.php';
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/class-form-action-confirmation-email.php';
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/class-form-post-type.php';
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/class-form-schema-builder.php';
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/class-form-render.php';
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/class-form-preview-rest.php';
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/class-form-processor.php';
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/class-form-rest.php';
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/class-form-integration-rest.php';
require_once GENERATEBLOCKS_PRO_DIR . 'includes/form/class-form-submissions.php';
// Initialize form system on 'init' so third-party code can hook in.
// Third-party form integrations can register on the
// generateblocks_form_register_integrations hook.
add_action( 'init', 'generateblocks_pro_init_form_system' );
// Hide the form no-JS fallback inside pattern-library preview thumbnails.
add_action( 'wp_head', 'generateblocks_pro_form_hide_preview_noscript' );
/**
* Initialize the form system.
*/
function generateblocks_pro_init_form_system() {
/**
* Register email integration API keys.
*
* Use generateblocks_pro_set_email_integration() inside this hook
* to provide API keys for email marketing services.
*
* @since 2.6.0
*
* @example
* add_action( 'generateblocks_form_register_email_integrations', function() {
* generateblocks_pro_set_email_integration( 'mailchimp', 'your-api-key' );
* } );
*/
do_action( 'generateblocks_form_register_email_integrations' );
GenerateBlocks_Pro_Form_Action_Email::init();
GenerateBlocks_Pro_Form_Action_Email_Signup::init();
GenerateBlocks_Pro_Form_Action_Webhook::init();
GenerateBlocks_Pro_Form_Action_Confirmation_Email::init();
GenerateBlocks_Pro_Form_Turnstile::init();
/**
* Register custom form integration providers.
*
* Use generateblocks_pro_register_form_integration() inside this hook to
* expose a provider in the form editor.
* This runs after built-ins, so registering an existing ID intentionally
* replaces the built-in definition for this request.
*
* @since 2.6.0
*/
do_action( 'generateblocks_form_register_integrations' );
GenerateBlocks_Pro_Form_Post_Type::get_instance()->init();
GenerateBlocks_Pro_Form_Schema_Builder::get_instance()->init();
GenerateBlocks_Pro_Form_Preview_Rest::get_instance()->init();
GenerateBlocks_Pro_Form_Rest::get_instance()->init();
GenerateBlocks_Pro_Form_Integration_Rest::get_instance()->init();
GenerateBlocks_Pro_Form_Submissions::get_instance()->init();
}
/**
* Suppress the form no-JS fallback inside pattern-library preview thumbnails.
*
* Pattern previews load the form into a sandboxed iframe with no `allow-scripts`
* (the `?gb-template-viewer=1` shell), so with scripting disabled the form's
* <noscript> fallback renders in the thumbnail. Emitting this style only on that
* request keeps the message intact for genuine no-JS visitors on real pages.
*
* The preview iframe always loads the viewer via the `gb-template-viewer` query
* string, so a presence check on $_GET is enough — and reading the superglobal
* never touches $wp_query, so there is no query-not-ready fatal to guard.
*/
function generateblocks_pro_form_hide_preview_noscript() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only existence check, emits a static style and changes no state.
if ( ! isset( $_GET['gb-template-viewer'] ) ) {
return;
}
echo '<style id="gb-pro-form-preview-noscript">.gb-form noscript{display:none}</style>';
}
@@ -0,0 +1,391 @@
<?php
/**
* Confirmation email form action.
*
* Sends an auto-reply to the person who submitted the form.
* Plain text only. Never uses user data in the From header.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Confirmation email form action class.
*/
class GenerateBlocks_Pro_Form_Action_Confirmation_Email {
/**
* Register the action with the registry.
*/
public static function init() {
GenerateBlocks_Pro_Form_Action_Registry::register( 'confirmation-email', [ __CLASS__, 'send' ] );
}
/**
* Validate confirmation email settings before publishing or delivery.
*
* @param array $form_settings The form block settings.
* @param array|null $schema Optional derived field schema list.
* @return true|WP_Error
*/
public static function validate_settings( $form_settings, $schema = null ) {
if ( is_array( $schema ) ) {
$email_field = self::get_email_field( $form_settings );
if ( '' === $email_field ) {
return new WP_Error(
'gb_form_confirmation_email_field_missing',
__( 'Confirmation email field is required.', 'generateblocks-pro' ),
[ 'status' => 400 ]
);
}
$field = self::find_schema_field( $schema, $email_field );
if ( ! $field ) {
return new WP_Error(
'gb_form_confirmation_email_field_missing',
sprintf(
/* translators: %s: email field name. */
__( 'Confirmation email field "%s" does not exist.', 'generateblocks-pro' ),
$email_field
),
[ 'status' => 400 ]
);
}
$field_type = $field['type'] ?? '';
if ( ! in_array( $field_type, [ 'email', 'text' ], true ) ) {
return new WP_Error(
'gb_form_confirmation_email_field_invalid',
sprintf(
/* translators: %s: email field name. */
__( 'Confirmation email field "%s" must be an email or text field.', 'generateblocks-pro' ),
$email_field
),
[ 'status' => 400 ]
);
}
}
return true;
}
/**
* Validate the submitted confirmation email recipient before delivery.
*
* @param array $sanitized_data The sanitized form data.
* @param array $form_settings The form block settings.
* @return true|WP_Error
*/
public static function validate_submission( $sanitized_data, $form_settings ) {
$email_field = self::get_email_field( $form_settings );
if ( '' === $email_field ) {
return new WP_Error(
'missing_email_field',
'Confirmation email field is not configured.',
[ 'status' => 400 ]
);
}
// is_array catches both missing key and shape mutation by a
// generateblocks_form_validate_submission filter that returned
// a flat scalar instead of the [value, label, type] array.
$field = $sanitized_data[ $email_field ] ?? null;
if ( ! is_array( $field ) ) {
return new WP_Error(
'missing_email_field',
'Confirmation email field not found in submission.',
[ 'status' => 400 ]
);
}
$to = sanitize_email( (string) ( $field['value'] ?? '' ) );
if ( ! is_email( $to ) ) {
return new WP_Error(
'invalid_recipient',
'Invalid recipient email for confirmation.',
[ 'status' => 400 ]
);
}
return true;
}
/**
* Send a confirmation email to the submitter.
*
* @param array $sanitized_data The sanitized form data (field_name => ['value' => ..., 'label' => ..., 'type' => ...]).
* @param array $form_settings The form block settings.
* @param array $context Request context (post_id, page_url).
* @return true|WP_Error True on success, WP_Error on failure.
*/
public static function send( $sanitized_data, $form_settings, $context = [] ) {
$validation = self::validate_submission( $sanitized_data, $form_settings );
if ( is_wp_error( $validation ) ) {
return $validation;
}
$email_field = self::get_email_field( $form_settings );
$to_field = $sanitized_data[ $email_field ] ?? null;
$to = sanitize_email( is_array( $to_field ) ? (string) ( $to_field['value'] ?? '' ) : '' );
// Build subject with merge tag support.
$raw_subject = $form_settings['confirmationEmailSubject'] ?? '';
if ( empty( $raw_subject ) ) {
$raw_subject = sprintf(
/* translators: %s: site name */
__( 'Thank you for contacting %s', 'generateblocks-pro' ),
get_bloginfo( 'name' )
);
}
$subject = GenerateBlocks_Pro_Form_Sanitizer::sanitize_email_header(
self::replace_merge_tags( $raw_subject, $sanitized_data )
);
// Build body with merge tag support.
$raw_body = $form_settings['confirmationEmailBody'] ?? '';
if ( empty( $raw_body ) ) {
$raw_body = __( 'Thank you for your submission. We will get back to you soon.', 'generateblocks-pro' );
}
$body = self::replace_merge_tags( $raw_body, $sanitized_data );
// Plain text only. Never set From from user data.
$headers = [ 'Content-Type: text/plain; charset=UTF-8' ];
$reply_to_header = self::build_reply_to_header(
$form_settings['confirmationEmailReplyToEmail'] ?? '',
$form_settings['confirmationEmailReplyToName'] ?? ''
);
if ( '' !== $reply_to_header ) {
$headers[] = $reply_to_header;
}
/**
* Filter the confirmation email arguments before sending.
*
* @since 2.6.0
* @param array $email_args {
* Email arguments.
*
* @type string $to Recipient email address.
* @type string $subject Email subject line.
* @type string $body Email body content.
* @type array $headers Email headers.
* }
* @param array $sanitized_data The sanitized form data.
* @param array $form_settings The form block settings.
* @param array $context Request context.
*/
$email_args = apply_filters(
'generateblocks_form_confirmation_email_args',
[
'to' => $to,
'subject' => $subject,
'body' => $body,
'headers' => $headers,
],
$sanitized_data,
$form_settings,
$context
);
// Re-validate filter output: defensive against third-party callbacks
// returning values with embedded newlines (header injection) or
// reshaping the array entirely.
if ( ! is_array( $email_args ) ) {
$email_args = [];
}
$raw_to = $email_args['to'] ?? '';
// Two-pass sanitization: strip CR/LF/tab + percent-encoded variants
// before sanitize_email() so a filter that returned an address with
// embedded newlines can't leak into wp_mail's `To` header. Belt and
// suspenders — sanitize_email() rejects most malformed addresses
// already, but this matches how `subject` and `headers` are
// hardened above so the contract is symmetric.
$safe_to = is_string( $raw_to )
? sanitize_email( GenerateBlocks_Pro_Form_Sanitizer::sanitize_email_header( $raw_to ) )
: '';
$raw_subject = $email_args['subject'] ?? '';
$safe_subject = GenerateBlocks_Pro_Form_Sanitizer::sanitize_email_header(
is_string( $raw_subject ) ? $raw_subject : ''
);
$safe_headers = self::sanitize_filter_headers( $email_args['headers'] ?? [] );
$raw_body = $email_args['body'] ?? '';
$safe_body = is_string( $raw_body ) ? $raw_body : '';
if ( ! is_email( $safe_to ) ) {
return new WP_Error( 'confirmation_email_invalid_to', 'No valid email recipient.' );
}
// This email is submitter-facing: with no other sender customization,
// wp_mail sends as core's literal "WordPress". Swap that default for
// the site name, scoped to this send only.
add_filter( 'wp_mail_from_name', [ __CLASS__, 'filter_default_from_name' ], 999 );
try {
$sent = wp_mail(
$safe_to,
$safe_subject,
$safe_body,
$safe_headers
);
} finally {
remove_filter( 'wp_mail_from_name', [ __CLASS__, 'filter_default_from_name' ], 999 );
}
if ( ! $sent ) {
return new WP_Error( 'confirmation_email_failed', 'Failed to send confirmation email.' );
}
return true;
}
/**
* Swap core's literal "WordPress" default From name for the site name.
*
* Runs at priority 999 so any name customized earlier — an SMTP plugin
* or a site-wide wp_mail_from_name filter — passes through untouched;
* only the unmodified core default is replaced.
*
* @param mixed $name Current From display name.
* @return mixed From display name.
*/
public static function filter_default_from_name( $name ) {
if ( 'WordPress' !== $name ) {
return $name;
}
$site_name = trim(
GenerateBlocks_Pro_Form_Sanitizer::sanitize_email_header(
wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES )
)
);
return '' !== $site_name ? $site_name : $name;
}
/**
* Re-validate the `headers` array returned by a filter callback.
*
* Note: we only strip CR/LF/tab and their percent-encoded variants here.
* Running sanitize_text_field() would mangle valid display-name email
* syntax like "Reply-To: Name <a@b.com>" (strip_tags eats the <...>).
*
* @param mixed $headers The filtered headers value.
* @return array Sanitized headers.
*/
private static function sanitize_filter_headers( $headers ) {
if ( ! is_array( $headers ) ) {
return [];
}
$safe = [];
foreach ( $headers as $header ) {
if ( ! is_string( $header ) ) {
continue;
}
$header = preg_replace( '/[\r\n\t]/', '', $header );
$header = str_replace( [ '%0a', '%0d', '%0A', '%0D' ], '', $header );
$header = trim( $header );
if ( '' !== $header ) {
$safe[] = $header;
}
}
return $safe;
}
/**
* Build a safe Reply-To header from static confirmation email settings.
*
* @param mixed $raw_email Configured reply-to email.
* @param mixed $raw_name Configured reply-to display name.
* @return string Header line, or empty string when no valid email exists.
*/
private static function build_reply_to_header( $raw_email, $raw_name ) {
$email = is_scalar( $raw_email ) ? sanitize_email( (string) $raw_email ) : '';
if ( ! is_email( $email ) ) {
return '';
}
$email = GenerateBlocks_Pro_Form_Sanitizer::sanitize_email_header( $email );
$name = is_scalar( $raw_name ) ? sanitize_text_field( (string) $raw_name ) : '';
$name = GenerateBlocks_Pro_Form_Sanitizer::sanitize_email_header( $name );
$name = trim( str_replace( [ '<', '>', '"', ',', ':', '\\' ], '', $name ) );
if ( '' === $name ) {
return 'Reply-To: ' . $email;
}
return 'Reply-To: ' . $name . ' <' . $email . '>';
}
/**
* Replace {field:name} merge tags with sanitized field values.
*
* Uses sanitize_textarea_field() for textarea fields to preserve newlines,
* and sanitize_text_field() for everything else.
*
* @param string $text The text containing merge tags.
* @param array $sanitized_data The sanitized form data.
* @return string Text with merge tags replaced.
*/
public static function replace_merge_tags( $text, $sanitized_data ) {
return GenerateBlocks_Pro_Form_Sanitizer::replace_merge_tags( $text, $sanitized_data );
}
/**
* Get the configured recipient field.
*
* @param array $form_settings The form block settings.
* @return string
*/
private static function get_email_field( $form_settings ) {
if (
isset( $form_settings['confirmationEmailField'] )
&& ( is_array( $form_settings['confirmationEmailField'] ) || is_object( $form_settings['confirmationEmailField'] ) )
) {
return '';
}
return isset( $form_settings['confirmationEmailField'] )
? sanitize_key( $form_settings['confirmationEmailField'] )
: '';
}
/**
* Find a field in the derived schema by name.
*
* @param array $schema Field schema list.
* @param string $name Field name.
* @return array|null
*/
private static function find_schema_field( $schema, $name ) {
foreach ( $schema as $field ) {
if ( is_array( $field ) && ( $field['name'] ?? '' ) === $name ) {
return $field;
}
}
return null;
}
}
@@ -0,0 +1,397 @@
<?php
/**
* Email signup form action.
*
* Routes a submission to the selected email marketing provider.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Email signup action class.
*/
class GenerateBlocks_Pro_Form_Action_Email_Signup {
/**
* Registered action name.
*/
const ACTION = 'email-signup';
/**
* Register the action with the action registry.
*/
public static function init() {
GenerateBlocks_Pro_Form_Action_Registry::register( self::ACTION, [ __CLASS__, 'handle' ] );
}
/**
* Handle an email signup submission.
*
* @param array $sanitized_data The sanitized form data.
* @param array $form_settings The form block settings.
* @param array $context Request context.
* @return true|WP_Error
*/
public static function handle( $sanitized_data, $form_settings, $context = [] ) {
$settings = self::normalize_settings( self::get_settings( $form_settings ) );
$validation = self::validate_settings( $settings );
if ( is_wp_error( $validation ) ) {
return $validation;
}
$submission_check = self::validate_submission( $sanitized_data, $settings );
if ( is_wp_error( $submission_check ) ) {
return $submission_check;
}
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $settings['provider'] );
$callback = $provider['subscribe_callback'] ?? null;
if ( ! is_callable( $callback ) ) {
return new WP_Error( 'email_signup_unavailable', 'Selected email signup provider is unavailable.' );
}
return call_user_func( $callback, $sanitized_data, $settings, $context );
}
/**
* Extract email-signup settings from form config.
*
* @param array $form_settings Form settings.
* @return array
*/
private static function get_settings( $form_settings ) {
return isset( $form_settings['actionSettings'][ self::ACTION ] ) && is_array( $form_settings['actionSettings'][ self::ACTION ] )
? $form_settings['actionSettings'][ self::ACTION ]
: [];
}
/**
* Normalize email-signup settings before validation/dispatch.
*
* @param array $settings Email signup settings.
* @return array
*/
private static function normalize_settings( $settings ) {
$settings = is_array( $settings ) ? $settings : [];
$settings['provider'] = isset( $settings['provider'] ) ? sanitize_key( $settings['provider'] ) : '';
if ( isset( $settings['emailField'] ) && ( is_array( $settings['emailField'] ) || is_object( $settings['emailField'] ) ) ) {
$settings['emailField'] = '';
}
$email_field = isset( $settings['emailField'] ) ? sanitize_key( $settings['emailField'] ) : '';
$settings['emailField'] = '' !== $email_field ? $email_field : 'email';
if ( isset( $settings['nameField'] ) && ( is_array( $settings['nameField'] ) || is_object( $settings['nameField'] ) ) ) {
$settings['nameField'] = '';
}
$settings['nameField'] = isset( $settings['nameField'] ) ? sanitize_key( $settings['nameField'] ) : '';
return $settings;
}
/**
* Validate the provider-level email signup settings.
*
* Provider callbacks still validate provider-specific ID shapes and field
* maps before making external requests.
*
* @param array $settings Email signup settings.
* @param array|null $schema Optional form field schema list.
* @return true|WP_Error
*/
public static function validate_settings( $settings, $schema = null ) {
$settings = self::normalize_settings( $settings );
$provider_id = $settings['provider'];
if ( '' === $provider_id ) {
return new WP_Error(
'gb_form_email_signup_provider_missing',
__( 'Email signup service is required.', 'generateblocks-pro' ),
[ 'status' => 400 ]
);
}
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $provider_id );
if ( ! $provider || 'email' !== $provider['type'] ) {
return new WP_Error(
'gb_form_email_signup_provider_invalid',
__( 'Selected email signup service is unavailable.', 'generateblocks-pro' ),
[ 'status' => 400 ]
);
}
if ( ! is_callable( $provider['subscribe_callback'] ?? null ) ) {
return new WP_Error(
'gb_form_email_signup_provider_unavailable',
__( 'Selected email signup service cannot process submissions.', 'generateblocks-pro' ),
[ 'status' => 400 ]
);
}
$connection_check = self::validate_provider_connection( $provider_id, $provider );
if ( is_wp_error( $connection_check ) ) {
return $connection_check;
}
$provider_settings_check = self::validate_provider_settings( $settings, $provider );
if ( is_wp_error( $provider_settings_check ) ) {
return $provider_settings_check;
}
$destination = $settings['destinationId'] ?? '';
if (
! empty( $provider['destination']['required'] )
&& ( is_array( $destination ) || is_object( $destination ) || '' === trim( (string) $destination ) )
) {
return new WP_Error(
'gb_form_email_signup_destination_missing',
sprintf(
/* translators: %s: destination label, e.g. Audience/List/Form. */
__( 'Email signup %s is required.', 'generateblocks-pro' ),
$provider['destination']['label'] ? strtolower( $provider['destination']['label'] ) : __( 'destination', 'generateblocks-pro' )
),
[ 'status' => 400 ]
);
}
$secondary_destination = $settings['secondaryDestinationId'] ?? '';
if (
! empty( $provider['secondary_destination']['required'] )
&& ( is_array( $secondary_destination ) || is_object( $secondary_destination ) || '' === trim( (string) $secondary_destination ) )
) {
return new WP_Error(
'gb_form_email_signup_secondary_destination_missing',
sprintf(
/* translators: %s: secondary destination label, e.g. Tag. */
__( 'Email signup %s is required.', 'generateblocks-pro' ),
$provider['secondary_destination']['label'] ? strtolower( $provider['secondary_destination']['label'] ) : __( 'secondary destination', 'generateblocks-pro' )
),
[ 'status' => 400 ]
);
}
if ( is_array( $schema ) ) {
$email_field = $settings['emailField'];
$field = self::find_schema_field( $schema, $email_field );
if ( ! $field ) {
return new WP_Error(
'gb_form_email_signup_email_field_missing',
sprintf(
/* translators: %s: email field name. */
__( 'Email signup email field "%s" does not exist.', 'generateblocks-pro' ),
$email_field
),
[ 'status' => 400 ]
);
}
$field_type = $field['type'] ?? '';
if ( ! in_array( $field_type, [ 'email', 'text' ], true ) ) {
return new WP_Error(
'gb_form_email_signup_email_field_invalid',
sprintf(
/* translators: %s: email field name. */
__( 'Email signup email field "%s" must be an email or text field.', 'generateblocks-pro' ),
$email_field
),
[ 'status' => 400 ]
);
}
if ( ! empty( $provider['supports_name_field'] ) && '' !== $settings['nameField'] ) {
$name_field = self::find_schema_field( $schema, $settings['nameField'] );
if ( ! $name_field ) {
return new WP_Error(
'gb_form_email_signup_name_field_missing',
sprintf(
/* translators: %s: name field name. */
__( 'Email signup name field "%s" does not exist.', 'generateblocks-pro' ),
$settings['nameField']
),
[ 'status' => 400 ]
);
}
if ( 'text' !== ( $name_field['type'] ?? '' ) ) {
return new WP_Error(
'gb_form_email_signup_name_field_invalid',
sprintf(
/* translators: %s: name field name. */
__( 'Email signup name field "%s" must be a text field.', 'generateblocks-pro' ),
$settings['nameField']
),
[ 'status' => 400 ]
);
}
}
}
return true;
}
/**
* Validate provider-specific email signup settings.
*
* @param array $settings Email signup settings.
* @param array $provider Provider definition.
* @return true|WP_Error
*/
private static function validate_provider_settings( $settings, $provider ) {
foreach ( $provider['settings_fields'] ?? [] as $field ) {
if ( empty( $field['required'] ) ) {
continue;
}
$key = $field['key'] ?? '';
$value = '' !== $key ? ( $settings[ $key ] ?? '' ) : '';
if ( is_array( $value ) || is_object( $value ) || '' === trim( (string) $value ) ) {
return new WP_Error(
'gb_form_email_signup_setting_missing',
sprintf(
/* translators: %s: setting label. */
__( 'Email signup %s is required.', 'generateblocks-pro' ),
$field['label'] ? strtolower( $field['label'] ) : __( 'setting', 'generateblocks-pro' )
),
[ 'status' => 400 ]
);
}
}
if ( is_callable( $provider['settings_validate_callback'] ?? null ) ) {
$result = call_user_func( $provider['settings_validate_callback'], $settings );
if ( is_wp_error( $result ) ) {
return $result;
}
}
return true;
}
/**
* Validate the submitted email signup data before provider dispatch.
*
* @param array $sanitized_data The sanitized form data.
* @param array $settings Email signup settings.
* @return true|WP_Error
*/
public static function validate_submission( $sanitized_data, $settings ) {
$settings = self::normalize_settings( $settings );
$email_field = $settings['emailField'];
if ( ! isset( $sanitized_data[ $email_field ] ) ) {
return new WP_Error(
'gb_form_email_signup_email_missing',
__( 'Email signup requires a valid email address.', 'generateblocks-pro' ),
[ 'status' => 400 ]
);
}
$email = (string) ( $sanitized_data[ $email_field ]['value'] ?? '' );
if ( ! is_email( $email ) ) {
return new WP_Error(
'gb_form_email_signup_email_invalid',
__( 'Email signup requires a valid email address.', 'generateblocks-pro' ),
[ 'status' => 400 ]
);
}
return true;
}
/**
* Validate that the selected provider is connected before publishing or
* dispatching. Provider-specific handlers still validate request payloads.
*
* @param string $provider_id Provider ID.
* @param array $provider Provider definition.
* @return true|WP_Error
*/
private static function validate_provider_connection( $provider_id, $provider ) {
$connection = $provider['connection'] ?? [];
if ( empty( $connection ) || 'none' === ( $connection['type'] ?? 'none' ) ) {
return true;
}
if ( is_callable( $connection['connected_callback'] ?? null ) ) {
$result = call_user_func( $connection['connected_callback'], $provider_id );
if ( is_wp_error( $result ) ) {
return new WP_Error(
'gb_form_email_signup_provider_not_connected',
$result->get_error_message(),
[ 'status' => 400 ]
);
}
if ( ! $result ) {
return new WP_Error(
'gb_form_email_signup_provider_not_connected',
__( 'Selected email signup service is not connected.', 'generateblocks-pro' ),
[ 'status' => 400 ]
);
}
}
foreach ( $connection['fields'] ?? [] as $field ) {
if ( empty( $field['required'] ) ) {
continue;
}
$value = GenerateBlocks_Pro_Form_Integration_Rest::get_connection_setting_for( $provider_id, $field['key'] );
if ( '' === trim( (string) $value ) ) {
return new WP_Error(
'gb_form_email_signup_provider_not_connected',
sprintf(
/* translators: %s: connection setting label */
__( 'Email signup service is not connected. %s is required.', 'generateblocks-pro' ),
$field['label']
),
[ 'status' => 400 ]
);
}
}
return true;
}
/**
* Find a field in the derived schema by name.
*
* @param array $schema Field schema list.
* @param string $name Field name.
* @return array|null
*/
private static function find_schema_field( $schema, $name ) {
foreach ( $schema as $field ) {
if ( is_array( $field ) && ( $field['name'] ?? '' ) === $name ) {
return $field;
}
}
return null;
}
}
@@ -0,0 +1,772 @@
<?php
/**
* Email form action.
*
* Sends form submissions via wp_mail(). Never uses user data in the From email
* address; From name may use sanitized merge tags.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Email form action class.
*/
class GenerateBlocks_Pro_Form_Action_Email {
/**
* Hard cap on recipient count per submission. emailTo is admin-controlled
* (manage_options to write form meta), so submitters can't reach this. The
* cap protects against misconfiguration — pasting a giant CSV — amplifying
* outbound mail per submission.
*/
const MAX_RECIPIENTS = 10;
/**
* Stored value that explicitly disables the Reply-To header.
*/
const REPLY_TO_NONE = '__none';
/**
* Register the email action with the registry.
*/
public static function init() {
GenerateBlocks_Pro_Form_Action_Registry::register( 'email', [ __CLASS__, 'send' ] );
}
/**
* Validate the email action settings before delivery starts.
*
* @param array $form_settings The form block settings.
* @return true|WP_Error
*/
public static function validate_settings( $form_settings ) {
$to = self::parse_recipients(
! empty( $form_settings['emailTo'] ) ? $form_settings['emailTo'] : get_option( 'admin_email' )
);
if ( empty( $to ) ) {
return new WP_Error( 'invalid_recipient', 'Invalid email recipient.' );
}
return true;
}
/**
* Send the form submission via email.
*
* @param array $sanitized_data The sanitized form data (field_name => ['value' => ..., 'label' => ..., 'type' => ...]).
* @param array $form_settings The form block settings.
* @param array $context Request context (post_id, page_url).
* @return true|WP_Error True on success, WP_Error on failure.
*/
public static function send( $sanitized_data, $form_settings, $context = [] ) {
$validation = self::validate_settings( $form_settings );
if ( is_wp_error( $validation ) ) {
return $validation;
}
// Determine recipients — supports comma-separated addresses.
$to = self::parse_recipients(
! empty( $form_settings['emailTo'] ) ? $form_settings['emailTo'] : get_option( 'admin_email' )
);
// Build subject with site name for context.
$site_name = get_bloginfo( 'name' );
if (
isset( $form_settings['emailSubject'] )
&& is_scalar( $form_settings['emailSubject'] )
&& '' !== (string) $form_settings['emailSubject']
) {
$subject = (string) $form_settings['emailSubject'];
} else {
$subject = sprintf(
/* translators: %s: site name */
__( '[%s] New Form Submission', 'generateblocks-pro' ),
$site_name
);
}
$subject = GenerateBlocks_Pro_Form_Sanitizer::sanitize_email_header(
GenerateBlocks_Pro_Form_Sanitizer::replace_merge_tags( $subject, $sanitized_data )
);
/**
* Filter whether admin notification emails should use the built-in HTML template.
*
* @since 2.6.0
* @param bool $send_html Whether to send HTML. Default true.
* @param array $sanitized_data The sanitized form data.
* @param array $form_settings The form block settings.
* @param array $context Request context.
*/
$send_html = (bool) apply_filters(
'generateblocks_form_email_use_html',
true,
$sanitized_data,
$form_settings,
$context
);
// Build headers. From email is static form config only; the display
// name may use sanitized merge tags.
$headers = [
$send_html
? 'Content-Type: text/html; charset=UTF-8'
: 'Content-Type: text/plain; charset=UTF-8',
];
$from_name = self::sanitize_from_name(
GenerateBlocks_Pro_Form_Sanitizer::replace_merge_tags(
$form_settings['emailFromName'] ?? '',
$sanitized_data
)
);
$from_email = self::sanitize_from_email( $form_settings['emailFromEmail'] ?? '' );
if ( '' !== $from_email ) {
$headers[] = self::build_from_header( $from_email, $from_name );
}
$reply_to = self::get_reply_to_email( $sanitized_data, $form_settings['emailReplyToField'] ?? '' );
if ( '' !== $reply_to ) {
$headers[] = 'Reply-To: ' . $reply_to;
}
$body = $send_html
? self::build_html_body( $sanitized_data, $context, $site_name )
: self::build_text_body( $sanitized_data, $context, $site_name );
/**
* Filter the email arguments before sending.
*
* @since 2.6.0
* @param array $email_args {
* Email arguments.
*
* @type array $to Recipient email addresses.
* @type string $subject Email subject line.
* @type string $body Email body content.
* @type array $headers Email headers.
* }
* @param array $sanitized_data The sanitized form data.
* @param array $form_settings The form block settings.
* @param array $context Request context.
*/
$email_args = apply_filters(
'generateblocks_form_email_args',
[
'to' => $to,
'subject' => $subject,
'body' => $body,
'headers' => $headers,
],
$sanitized_data,
$form_settings,
$context
);
// Re-validate filter output: defensive against third-party callbacks
// returning values with embedded newlines (header injection) or
// reshaping the array entirely.
if ( ! is_array( $email_args ) ) {
$email_args = [];
}
$safe_to = self::sanitize_filter_recipients( $email_args['to'] ?? [] );
if ( empty( $safe_to ) ) {
return new WP_Error( 'email_no_recipients', 'No valid email recipient.' );
}
$raw_subject = $email_args['subject'] ?? '';
$safe_subject = GenerateBlocks_Pro_Form_Sanitizer::sanitize_email_header(
is_string( $raw_subject ) ? $raw_subject : ''
);
$safe_headers = self::sanitize_filter_headers( $email_args['headers'] ?? [] );
$raw_body = $email_args['body'] ?? '';
$safe_body = is_string( $raw_body ) ? $raw_body : '';
$sent = self::send_mail(
$safe_to,
$safe_subject,
$safe_body,
$safe_headers,
'' === $from_email ? $from_name : ''
);
if ( ! $sent ) {
return new WP_Error( 'email_failed', 'Failed to send email.' );
}
return true;
}
/**
* Re-validate `to` returned by a filter callback. Accepts either a
* comma-separated string or an array of addresses.
*
* @param mixed $value The filtered `to` value.
* @return array Valid email addresses.
*/
private static function sanitize_filter_recipients( $value ) {
if ( is_string( $value ) ) {
return self::parse_recipients( $value );
}
if ( ! is_array( $value ) ) {
return [];
}
$valid = [];
foreach ( $value as $address ) {
if ( ! is_string( $address ) ) {
continue;
}
$address = sanitize_email( $address );
if ( is_email( $address ) ) {
$valid[] = $address;
if ( count( $valid ) >= self::MAX_RECIPIENTS ) {
break;
}
}
}
return $valid;
}
/**
* Resolve the configured Reply-To address.
*
* Empty config means auto-detect the first submitted email field. The
* explicit `__none` sentinel keeps an opt-out available without making the
* default contact-form path reply to the site sender.
*
* @param array $sanitized_data Submitted fields after schema-based sanitization.
* @param mixed $raw_field Configured reply-to field name.
* @return string Safe Reply-To email address, or empty string.
*/
private static function get_reply_to_email( $sanitized_data, $raw_field ) {
$reply_to_field = is_scalar( $raw_field ) ? sanitize_key( (string) $raw_field ) : '';
if ( self::REPLY_TO_NONE === $reply_to_field ) {
return '';
}
if ( '' !== $reply_to_field ) {
return self::get_reply_to_email_from_field( $sanitized_data, $reply_to_field );
}
foreach ( $sanitized_data as $field ) {
if ( ! is_array( $field ) || 'email' !== ( $field['type'] ?? '' ) ) {
continue;
}
return self::sanitize_reply_to_email( $field['value'] ?? '' );
}
return '';
}
/**
* Resolve Reply-To from a specific submitted field.
*
* @param array $sanitized_data Submitted fields after schema-based sanitization.
* @param string $field_name Sanitized field name.
* @return string Safe Reply-To email address, or empty string.
*/
private static function get_reply_to_email_from_field( $sanitized_data, $field_name ) {
$field = $sanitized_data[ $field_name ] ?? null;
if ( ! is_array( $field ) || 'email' !== ( $field['type'] ?? '' ) ) {
return '';
}
return self::sanitize_reply_to_email( $field['value'] ?? '' );
}
/**
* Sanitize and validate a Reply-To email address for header output.
*
* @param mixed $value Raw email value.
* @return string Safe Reply-To email address, or empty string.
*/
private static function sanitize_reply_to_email( $value ) {
$email = sanitize_email( is_scalar( $value ) ? (string) $value : '' );
if ( ! is_email( $email ) ) {
return '';
}
return GenerateBlocks_Pro_Form_Sanitizer::sanitize_email_header( $email );
}
/**
* Re-validate the `headers` array returned by a filter callback.
* Drops anything that's not a string and strips header-injection chars.
*
* Note: we only strip CR/LF/tab and their percent-encoded variants here.
* Running sanitize_text_field() would mangle valid display-name email
* syntax like "Reply-To: Name <a@b.com>" (strip_tags eats the <...>).
*
* @param mixed $headers The filtered headers value.
* @return array Sanitized headers.
*/
private static function sanitize_filter_headers( $headers ) {
if ( ! is_array( $headers ) ) {
return [];
}
$safe = [];
foreach ( $headers as $name => $header ) {
if ( is_string( $name ) && '' !== $name ) {
$header = $name . ': ' . ( is_scalar( $header ) ? (string) $header : '' );
}
if ( ! is_string( $header ) ) {
continue;
}
$header = preg_replace( '/[\r\n\t]/', '', $header );
$header = str_replace( [ '%0a', '%0d', '%0A', '%0D' ], '', $header );
$header = trim( $header );
if ( '' !== $header ) {
$safe[] = $header;
}
}
return $safe;
}
/**
* Send the email while forcing the content type parsed from our headers.
*
* WordPress applies the global wp_mail_content_type filter after parsing
* the headers passed to wp_mail(). Keep this message's MIME type scoped to
* the final sanitized headers so site-level filters cannot make HTML render
* as plain text or plain text render as HTML.
*
* @param array $to Recipient email addresses.
* @param string $subject Email subject.
* @param string $body Email body.
* @param array $headers Email headers.
* @param string $from_name Optional scoped From display name.
* @return bool Whether the email was sent.
*/
private static function send_mail( $to, $subject, $body, $headers, $from_name = '' ) {
$content_type = self::get_content_type_from_headers( $headers );
$content_type_filter = null;
$from_name = self::sanitize_from_name( $from_name );
$from_name_filter = null;
if ( '' !== $content_type ) {
$content_type_filter = static function () use ( $content_type ) {
return $content_type;
};
add_filter( 'wp_mail_content_type', $content_type_filter, 999 );
}
if ( '' !== $from_name && ! self::headers_include_from( $headers ) ) {
$from_name_filter = static function () use ( $from_name ) {
return $from_name;
};
add_filter( 'wp_mail_from_name', $from_name_filter, 999 );
}
try {
return wp_mail( $to, $subject, $body, $headers );
} finally {
if ( is_callable( $content_type_filter ) ) {
remove_filter( 'wp_mail_content_type', $content_type_filter, 999 );
}
if ( is_callable( $from_name_filter ) ) {
remove_filter( 'wp_mail_from_name', $from_name_filter, 999 );
}
}
}
/**
* Extract a supported MIME content type from headers.
*
* @param array $headers Email headers.
* @return string Supported content type, or an empty string.
*/
private static function get_content_type_from_headers( $headers ) {
$content_type = '';
foreach ( $headers as $header ) {
if ( ! is_string( $header ) || 0 !== stripos( $header, 'Content-Type:' ) ) {
continue;
}
$value = trim( substr( $header, strlen( 'Content-Type:' ) ) );
$type = strtolower( trim( explode( ';', $value, 2 )[0] ) );
if ( in_array( $type, [ 'text/html', 'text/plain' ], true ) ) {
$content_type = $type;
} elseif ( false !== strpos( $value, ';' ) || '' !== $type ) {
$content_type = '';
}
}
return $content_type;
}
/**
* Determine whether sanitized headers already include a From header.
*
* @param array $headers Email headers.
* @return bool
*/
private static function headers_include_from( $headers ) {
foreach ( $headers as $header ) {
if ( is_string( $header ) && 0 === stripos( trim( $header ), 'From:' ) ) {
return true;
}
}
return false;
}
/**
* Sanitize an admin-configured From email address.
*
* @param mixed $raw_email Raw From email setting.
* @return string Valid email address, or empty string.
*/
private static function sanitize_from_email( $raw_email ) {
$email = is_scalar( $raw_email ) ? sanitize_email( (string) $raw_email ) : '';
if ( ! is_email( $email ) ) {
return '';
}
return GenerateBlocks_Pro_Form_Sanitizer::sanitize_email_header( $email );
}
/**
* Sanitize a From display name.
*
* @param mixed $raw_name Raw From name setting.
* @return string Safe display name, or empty string.
*/
private static function sanitize_from_name( $raw_name ) {
$name = is_scalar( $raw_name ) ? sanitize_text_field( (string) $raw_name ) : '';
$name = GenerateBlocks_Pro_Form_Sanitizer::sanitize_email_header( $name );
return trim( str_replace( [ '<', '>', '"', ',', ':', '\\' ], '', $name ) );
}
/**
* Build a safe From header from static form settings.
*
* @param string $email Valid From email address.
* @param string $name Optional display name.
* @return string Header line.
*/
private static function build_from_header( $email, $name ) {
if ( '' === $email ) {
return '';
}
if ( '' === $name ) {
return 'From: ' . $email;
}
return 'From: ' . $name . ' <' . $email . '>';
}
/**
* Parse and validate a comma-separated list of email recipients.
*
* @param string $raw Comma-separated email addresses.
* @return array Valid email addresses, or empty array if none valid.
*/
public static function parse_recipients( $raw ) {
$addresses = array_map( 'trim', explode( ',', $raw ) );
$valid = [];
foreach ( $addresses as $address ) {
$address = sanitize_email( $address );
if ( is_email( $address ) ) {
$valid[] = $address;
if ( count( $valid ) >= self::MAX_RECIPIENTS ) {
break;
}
}
}
return $valid;
}
/**
* Build the email body as HTML.
*
* @param array $sanitized_data The sanitized form data.
* @param array $context Request context.
* @param string $site_name The site name.
* @return string The formatted email body.
*/
private static function build_html_body( $sanitized_data, $context, $site_name ) {
$fields = [];
$rows = '';
foreach ( $sanitized_data as $field_name => $field ) {
if ( ! is_array( $field ) ) {
continue;
}
$label = sanitize_text_field( $field['label'] ?? $field_name );
$value = wp_strip_all_tags( (string) ( $field['value'] ?? '' ), false );
if ( '' === $value ) {
continue;
}
$fields[] = [
'label' => $label,
'value' => $value,
'type' => (string) ( $field['type'] ?? 'text' ),
];
}
$field_count = count( $fields );
foreach ( $fields as $index => $field ) {
$rows .= self::build_html_field_row(
$field['label'],
$field['value'],
$field['type'],
$index === $field_count - 1
);
}
if ( '' === $rows ) {
$rows = '<tr><td style="padding:20px 0;color:#666666;font-family:Arial,sans-serif;font-size:14px;line-height:1.6;">'
. esc_html__( 'No field values were submitted.', 'generateblocks-pro' )
. '</td></tr>';
}
$meta_rows = self::build_html_meta_rows( $context, $site_name );
return '<table role="presentation" width="100%" cellpadding="24" cellspacing="0" border="0" bgcolor="#f5f5f5">'
. '<tr><td align="center">'
. '<table role="presentation" width="640" cellpadding="0" cellspacing="0" border="0" bgcolor="#ffffff">'
. '<tr><td style="padding:10px 36px 24px 36px;">'
. '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">'
. $rows
. '</table>'
. '</td></tr>'
. ( '' !== $meta_rows
? '<tr><td bgcolor="#fafafa" style="border-top:1px solid #eeeeee;padding:18px 36px;">'
. '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">'
. $meta_rows
. '</table>'
. '</td></tr>'
: '' )
. '</table>'
. '</td></tr>'
. '</table>';
}
/**
* Build one field row for the HTML email body.
*
* @param string $label Field label.
* @param string $value Field value.
* @param string $type Field type.
* @param bool $last Whether this is the last field row.
* @return string Field row HTML.
*/
private static function build_html_field_row( $label, $value, $type, $last ) {
$border = $last ? '' : 'border-bottom:1px solid #eeeeee;';
return '<tr><td style="padding:16px 0 4px 0;color:#767676;font-family:Arial,sans-serif;font-size:12px;">'
. esc_html( self::format_label( $label ) )
. '</td></tr>'
. '<tr><td style="' . $border . 'color:#1f1f1f;font-family:Arial,sans-serif;font-size:15px;line-height:1.6;padding:0 0 16px 0;">'
. self::format_html_value( $value, $type )
. '</td></tr>';
}
/**
* Format a submitted value for HTML email output.
*
* @param string $value Field value.
* @param string $type Field type.
* @return string HTML-safe value.
*/
private static function format_html_value( $value, $type ) {
$value = str_replace( [ "\r\n", "\r" ], "\n", $value );
if ( 'email' === $type ) {
$email = sanitize_email( $value );
if ( is_email( $email ) ) {
return '<a href="' . esc_url( 'mailto:' . $email ) . '" style="color:#1a5fb4;">'
. esc_html( $value )
. '</a>';
}
}
if ( 'url' === $type ) {
$url = esc_url( $value );
if ( '' !== $url ) {
return '<a href="' . $url . '" style="color:#1a5fb4;">'
. esc_html( $value )
. '</a>';
}
}
return nl2br( esc_html( $value ), false );
}
/**
* Build metadata rows for the HTML email footer.
*
* @param array $context Request context.
* @param string $site_name Site name.
* @return string Metadata rows HTML.
*/
private static function build_html_meta_rows( $context, $site_name ) {
$rows = '';
if ( '' !== $site_name ) {
$rows .= self::build_html_meta_row( __( 'Site', 'generateblocks-pro' ), esc_html( $site_name ) );
}
$form_title = self::get_form_title( $context );
if ( '' !== $form_title ) {
$rows .= self::build_html_meta_row( __( 'Form', 'generateblocks-pro' ), esc_html( $form_title ) );
}
if ( ! empty( $context['page_url'] ) ) {
$url = esc_url( $context['page_url'] );
if ( '' !== $url ) {
$rows .= self::build_html_meta_row(
__( 'Page', 'generateblocks-pro' ),
'<a href="' . $url . '" style="color:#1a5fb4;">' . esc_html( $context['page_url'] ) . '</a>'
);
}
}
$rows .= self::build_html_meta_row(
__( 'Submitted', 'generateblocks-pro' ),
esc_html( wp_date( get_option( 'date_format' ) . ' @ ' . get_option( 'time_format' ) ) )
);
return $rows;
}
/**
* Build one metadata row for the HTML email footer.
*
* @param string $label Metadata label.
* @param string $value Metadata value HTML.
* @return string Metadata row HTML.
*/
private static function build_html_meta_row( $label, $value ) {
return '<tr>'
. '<td valign="top" style="color:#767676;font-family:Arial,sans-serif;font-size:12px;line-height:1.5;padding:3px 14px 3px 0;">'
. esc_html( self::format_label( $label ) )
. '</td>'
. '<td valign="top" style="color:#444444;font-family:Arial,sans-serif;font-size:13px;line-height:1.5;padding:3px 0;">'
. $value
. '</td>'
. '</tr>';
}
/**
* Format labels without depending on CSS text-transform support.
*
* @param string $label Label text.
* @return string Uppercase label.
*/
private static function format_label( $label ) {
return function_exists( 'mb_strtoupper' ) ? mb_strtoupper( $label ) : strtoupper( $label );
}
/**
* Get the form title from the request context.
*
* @param array $context Request context.
* @return string Form title.
*/
private static function get_form_title( $context ) {
$form_id = isset( $context['form_id'] ) ? (int) $context['form_id'] : 0;
if ( $form_id <= 0 ) {
return '';
}
return sanitize_text_field( get_the_title( $form_id ) );
}
/**
* Build the email body as plain text.
*
* Uses uppercase labels on their own line for a clear scan pattern.
* Long values and multi-line content (textareas) flow naturally.
* The email client handles all rendering — fonts, colors, dark mode.
*
* @param array $sanitized_data The sanitized form data.
* @param array $context Request context.
* @param string $site_name The site name.
* @return string The formatted email body.
*/
private static function build_text_body( $sanitized_data, $context, $site_name ) {
$lines = [];
foreach ( $sanitized_data as $field_name => $field ) {
if ( ! is_array( $field ) ) {
continue;
}
$label = sanitize_text_field( $field['label'] ?? $field_name );
$value = wp_strip_all_tags( (string) ( $field['value'] ?? '' ) );
if ( '' === $value ) {
continue;
}
$lines[] = function_exists( 'mb_strtoupper' ) ? mb_strtoupper( $label ) : strtoupper( $label );
$lines[] = $value;
$lines[] = '';
}
// Divider before metadata.
$lines[] = str_repeat( "\xe2\x94\x80", 40 );
// Metadata on compact lines.
$meta = [ $site_name ];
if ( ! empty( $context['page_url'] ) ) {
$meta[] = $context['page_url'];
}
$meta[] = wp_date( get_option( 'date_format' ) . ' @ ' . get_option( 'time_format' ) );
$lines[] = implode( " \xc2\xb7 ", $meta );
return implode( "\n", $lines );
}
}
@@ -0,0 +1,67 @@
<?php
/**
* Form action registry.
*
* Extensible system for registering form submission handlers.
* Third-party developers can register their own actions.
* All callbacks receive sanitized data only.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Form action registry class.
*/
class GenerateBlocks_Pro_Form_Action_Registry {
/**
* Registered actions.
*
* @var array<string, callable>
*/
private static $actions = [];
/**
* Register a form action.
*
* @param string $name The action identifier (e.g., 'email', 'mailchimp').
* @param callable $callback The handler function. Signature: function( array $sanitized_data, array $form_settings ): WP_Error|true.
*/
public static function register( $name, $callback ) {
if ( is_callable( $callback ) ) {
self::$actions[ sanitize_key( $name ) ] = $callback;
}
}
/**
* Get a registered action callback.
*
* @param string $name The action identifier.
* @return callable|null The callback or null if not registered.
*/
public static function get( $name ) {
return self::$actions[ sanitize_key( $name ) ] ?? null;
}
/**
* Unregister a form action.
*
* @param string $name The action identifier.
*/
public static function unregister( $name ) {
unset( self::$actions[ sanitize_key( $name ) ] );
}
/**
* Get all registered action names.
*
* @return array List of registered action identifiers.
*/
public static function get_registered() {
return array_keys( self::$actions );
}
}
@@ -0,0 +1,221 @@
<?php
/**
* Webhook form action.
*
* POSTs form submission data as JSON to a configured URL.
* Uses wp_safe_remote_post() to block requests to internal/private IPs.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Webhook form action class.
*/
class GenerateBlocks_Pro_Form_Action_Webhook {
/**
* Register the webhook action with the registry.
*/
public static function init() {
GenerateBlocks_Pro_Form_Action_Registry::register( 'webhook', [ __CLASS__, 'send' ] );
}
/**
* Validate the webhook action settings before delivery starts.
*
* @param array $form_settings The form block settings.
* @return true|WP_Error
*/
public static function validate_settings( $form_settings ) {
$url = self::get_valid_webhook_url( $form_settings );
return is_wp_error( $url ) ? $url : true;
}
/**
* Send the form submission to the configured webhook URL.
*
* @param array $sanitized_data The sanitized form data (field_name => ['value' => ..., 'label' => ..., 'type' => ...]).
* @param array $form_settings The form block settings.
* @param array $context Request context (post_id, page_url).
* @return true|WP_Error True on success, WP_Error on failure.
*/
public static function send( $sanitized_data, $form_settings, $context = [] ) {
$webhook_url = self::get_valid_webhook_url( $form_settings );
if ( is_wp_error( $webhook_url ) ) {
return $webhook_url;
}
return self::send_to_url( $webhook_url, $sanitized_data, $form_settings, $context );
}
/**
* Validate email-signup webhook provider settings.
*
* @param array $settings Email signup settings.
* @return true|WP_Error
*/
public static function validate_signup_settings( $settings ) {
$url = self::get_valid_url( $settings['url'] ?? '' );
return is_wp_error( $url ) ? $url : true;
}
/**
* Send an email signup submission to a webhook provider URL.
*
* @param array $sanitized_data The sanitized form data.
* @param array $settings Email signup settings.
* @param array $context Request context.
* @return true|WP_Error
*/
public static function subscribe( $sanitized_data, $settings, $context = [] ) {
$webhook_url = self::get_valid_url( $settings['url'] ?? '' );
if ( is_wp_error( $webhook_url ) ) {
return $webhook_url;
}
$form_settings = [
'actionSettings' => [
'email-signup' => $settings,
'webhook' => [
'url' => $settings['url'] ?? '',
],
],
];
return self::send_to_url( $webhook_url, $sanitized_data, $form_settings, $context );
}
/**
* Send sanitized form data to a validated webhook URL.
*
* @param string $webhook_url Validated webhook URL.
* @param array $sanitized_data The sanitized form data.
* @param array $form_settings Form settings passed to filters.
* @param array $context Request context.
* @return true|WP_Error
*/
private static function send_to_url( $webhook_url, $sanitized_data, $form_settings, $context = [] ) {
// Build a clean payload — field name => value (flat), plus metadata.
$fields = [];
foreach ( $sanitized_data as $field_name => $field ) {
if ( ! is_array( $field ) ) {
continue;
}
$fields[ $field_name ] = (string) ( $field['value'] ?? '' );
}
/**
* Filter the webhook payload before sending.
*
* Field values are sent flat at the top level — receivers like
* FluentCRM only map top-level keys (e.g. `email` is required there) —
* and repeated under `fields` for consumers that map structured paths.
* On a key collision a field value wins the top-level slot.
*
* @since 2.6.0
* @param array $payload The webhook payload.
* @param array $sanitized_data The full sanitized form data with labels and types.
* @param array $form_settings The form block settings.
* @param array $context Request context.
*/
$payload = apply_filters(
'generateblocks_form_webhook_payload',
array_merge(
[
'form_name' => ! empty( $context['form_id'] ) ? get_the_title( $context['form_id'] ) : '',
'fields' => $fields,
'page_url' => $context['page_url'] ?? '',
'timestamp' => gmdate( 'c' ),
],
$fields
),
$sanitized_data,
$form_settings,
$context
);
// wp_safe_remote_post blocks requests to internal/private IPs (127.0.0.1,
// 10.x.x.x, 169.254.169.254, etc.) via wp_http_validate_url(). This prevents
// SSRF since the webhook URL is user-supplied. Redirection is disabled so a
// 302 to an internal IP can't bypass the validation.
$response = wp_safe_remote_post(
$webhook_url,
[
'body' => wp_json_encode( $payload ),
'headers' => [ 'Content-Type' => 'application/json' ],
'timeout' => GenerateBlocks_Pro_Form_Http::DEFAULT_TIMEOUT,
'redirection' => 0,
'data_format' => 'body',
]
);
if ( is_wp_error( $response ) ) {
return new WP_Error(
'webhook_failed',
'Webhook request failed: ' . $response->get_error_message()
);
}
$status_code = wp_remote_retrieve_response_code( $response );
// Accept any 2xx response.
if ( $status_code < 200 || $status_code >= 300 ) {
return new WP_Error(
'webhook_failed',
'Webhook returned status ' . $status_code . '.'
);
}
return true;
}
/**
* Get the validated webhook URL.
*
* @param array $form_settings The form block settings.
* @return string|WP_Error
*/
private static function get_valid_webhook_url( $form_settings ) {
$webhook_url = $form_settings['actionSettings']['webhook']['url'] ?? '';
return self::get_valid_url( $webhook_url );
}
/**
* Validate a webhook URL.
*
* @param mixed $webhook_url Raw webhook URL.
* @return string|WP_Error
*/
private static function get_valid_url( $webhook_url ) {
if ( empty( $webhook_url ) ) {
return new WP_Error(
'missing_webhook_url',
'Webhook URL is not configured.',
[ 'status' => 400 ]
);
}
$webhook_url = esc_url_raw( $webhook_url, [ 'https', 'http' ] );
if ( empty( $webhook_url ) || ! wp_http_validate_url( $webhook_url ) ) {
return new WP_Error(
'invalid_webhook_url',
'Invalid webhook URL.',
[ 'status' => 400 ]
);
}
return $webhook_url;
}
}
@@ -0,0 +1,118 @@
<?php
/**
* Shared HTTP helpers for form integrations.
*
* Converts non-2xx responses from third-party APIs into actionable WP_Errors
* so auth failures, rate limits, and outages don't silently look like
* "no lists / no forms / no groups" in the editor.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Form HTTP helper class.
*/
class GenerateBlocks_Pro_Form_Http {
/**
* Default timeout for form submission HTTP actions.
*
* Multiple actions can run for a single submission, so keep this lower
* than WordPress' default to avoid tying up PHP workers for too long.
*/
const DEFAULT_TIMEOUT = 8;
/**
* Convert a non-2xx HTTP response into a WP_Error with a useful message.
*
* Returns null when the response is a 2xx so callers can continue. Checks
* common JSON error-message keys used across providers.
*
* @param array $response The wp_remote_* response (not a WP_Error — caller checks first).
* @param string $service Human-readable service name for the error message.
* @return WP_Error|null
*/
public static function response_error( $response, $service ) {
$status = wp_remote_retrieve_response_code( $response );
if ( $status >= 200 && $status < 300 ) {
return null;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
$detail = '';
if ( is_array( $body ) ) {
// Try each common error-message field.
foreach ( [ 'detail', 'message', 'error' ] as $key ) {
if ( ! empty( $body[ $key ] ) && is_string( $body[ $key ] ) ) {
$detail = $body[ $key ];
break;
}
}
}
$message = $service . ': ';
if ( 401 === $status || 403 === $status ) {
$message .= __( 'authentication failed — check your API key.', 'generateblocks-pro' );
} elseif ( 429 === $status ) {
$retry_after = wp_remote_retrieve_header( $response, 'retry-after' );
$message .= __( 'rate limit exceeded.', 'generateblocks-pro' );
if ( $retry_after ) {
$message .= ' ' . sprintf(
/* translators: %s: seconds */
__( 'Retry after %s seconds.', 'generateblocks-pro' ),
sanitize_text_field( (string) $retry_after )
);
}
} elseif ( $status >= 500 ) {
$message .= __( 'provider error — try again later.', 'generateblocks-pro' );
} elseif ( $detail ) {
$message .= sanitize_text_field( $detail );
} else {
$message .= sprintf(
/* translators: %d: HTTP status code */
__( 'unexpected response (HTTP %d).', 'generateblocks-pro' ),
$status
);
}
return new WP_Error( 'provider_error', $message, [ 'status' => $status ] );
}
/**
* Normalize a submitted field value for provider payloads.
*
* Form fields can produce scalar values or arrays (for example checkbox
* groups). Provider merge/custom-field APIs expect strings, so flatten arrays
* consistently and reject objects or nested non-scalar array values.
*
* @param mixed $value Submitted value.
* @return string
*/
public static function normalize_field_value( $value ) {
if ( is_array( $value ) ) {
$parts = [];
foreach ( $value as $part ) {
if ( is_scalar( $part ) ) {
$parts[] = (string) $part;
}
}
$value = implode( ', ', $parts );
}
if ( is_object( $value ) ) {
return '';
}
return trim( (string) $value );
}
}
@@ -0,0 +1,445 @@
<?php
/**
* Form integration provider registry.
*
* Stores provider definitions for fixed form integrations. Provider IDs are
* settings of a form action, not form actions themselves.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Integration registry class.
*/
class GenerateBlocks_Pro_Form_Integration_Registry {
/**
* Registered provider definitions.
*
* @var array<string, array>
*/
private static $integrations = [];
/**
* Registered API keys.
*
* @var array<string, string>
*/
private static $keys = [];
/**
* Register an integration provider.
*
* @param array $args Provider definition.
* @return bool True when registered.
*/
public static function register( $args ) {
if ( ! is_array( $args ) ) {
return false;
}
$id = isset( $args['id'] ) ? sanitize_key( $args['id'] ) : '';
if ( '' === $id || empty( $args['label'] ) ) {
return false;
}
self::$integrations[ $id ] = [
'id' => $id,
'label' => sanitize_text_field( (string) $args['label'] ),
'type' => isset( $args['type'] ) ? sanitize_key( $args['type'] ) : 'email',
'help' => isset( $args['help'] ) ? sanitize_text_field( (string) $args['help'] ) : '',
'connection' => self::normalize_connection( $args['connection'] ?? [] ),
'destination' => self::normalize_destination( $args['destination'] ?? [] ),
'secondary_destination' => self::normalize_destination( $args['secondary_destination'] ?? [], false ),
'settings_fields' => self::normalize_settings_fields( $args['settings_fields'] ?? [] ),
'settings_validate_callback' => isset( $args['settings_validate_callback'] ) && is_callable( $args['settings_validate_callback'] )
? $args['settings_validate_callback']
: null,
'field_map' => self::normalize_field_map( $args['field_map'] ?? [] ),
'supports_name_field' => ! empty( $args['supports_name_field'] ),
'supports_double_optin' => ! empty( $args['supports_double_optin'] ),
'default_double_optin' => ! array_key_exists( 'default_double_optin', $args ) || ! empty( $args['default_double_optin'] ),
'subscribe_callback' => isset( $args['subscribe_callback'] ) && is_callable( $args['subscribe_callback'] )
? $args['subscribe_callback']
: null,
];
if ( method_exists( 'GenerateBlocks_Pro_Form_Integration_Rest', 'clear_service_cache' ) ) {
GenerateBlocks_Pro_Form_Integration_Rest::clear_service_cache( $id );
}
return true;
}
/**
* Normalize a connection definition.
*
* @param array $connection Raw connection definition.
* @return array
*/
private static function normalize_connection( $connection ) {
$connection = is_array( $connection ) ? $connection : [];
$type = isset( $connection['type'] ) ? sanitize_key( $connection['type'] ) : 'none';
$fields = isset( $connection['fields'] ) && is_array( $connection['fields'] )
? $connection['fields']
: [];
if ( 'api_key' === $type && empty( $fields ) ) {
$fields = [
[
'key' => 'api_key',
'label' => __( 'API Key', 'generateblocks-pro' ),
'type' => 'password',
'required' => true,
],
];
}
return [
'type' => $type,
'fields' => self::normalize_connection_fields( $fields ),
'test_callback' => isset( $connection['test_callback'] ) && is_callable( $connection['test_callback'] )
? $connection['test_callback']
: null,
'connected_callback' => isset( $connection['connected_callback'] ) && is_callable( $connection['connected_callback'] )
? $connection['connected_callback']
: null,
];
}
/**
* Normalize connection fields.
*
* @param array $fields Raw fields.
* @return array
*/
private static function normalize_connection_fields( $fields ) {
$out = [];
foreach ( $fields as $field ) {
if ( ! is_array( $field ) ) {
continue;
}
$key = isset( $field['key'] ) ? preg_replace( '/[^A-Za-z0-9_]/', '', (string) $field['key'] ) : '';
if ( '' === $key || empty( $field['label'] ) ) {
continue;
}
$out[] = [
'key' => $key,
'label' => sanitize_text_field( (string) $field['label'] ),
'type' => isset( $field['type'] ) ? sanitize_key( $field['type'] ) : 'text',
'help' => isset( $field['help'] ) ? sanitize_text_field( (string) $field['help'] ) : '',
'placeholder' => isset( $field['placeholder'] ) ? sanitize_text_field( (string) $field['placeholder'] ) : '',
'required' => ! array_key_exists( 'required', $field ) || ! empty( $field['required'] ),
'test_required' => array_key_exists( 'test_required', $field )
? ! empty( $field['test_required'] )
: ( ! array_key_exists( 'required', $field ) || ! empty( $field['required'] ) ),
'define' => isset( $field['define'] ) ? preg_replace( '/[^A-Z0-9_]/', '', (string) $field['define'] ) : '',
'public' => ! empty( $field['public'] ),
];
}
return $out;
}
/**
* Normalize the provider destination definition.
*
* @param array $destination Raw definition.
* @param bool $default_required Whether the destination is required.
* @return array
*/
private static function normalize_destination( $destination, $default_required = true ) {
$destination = is_array( $destination ) ? $destination : [];
$has_destination = ! empty( $destination['label'] ) || isset( $destination['options_callback'] );
return [
'label' => isset( $destination['label'] ) ? sanitize_text_field( (string) $destination['label'] ) : '',
'empty_label' => isset( $destination['empty_label'] ) ? sanitize_text_field( (string) $destination['empty_label'] ) : __( '- Select -', 'generateblocks-pro' ),
'refresh_label' => isset( $destination['refresh_label'] ) ? sanitize_text_field( (string) $destination['refresh_label'] ) : __( 'Refresh', 'generateblocks-pro' ),
'required' => array_key_exists( 'required', $destination ) ? ! empty( $destination['required'] ) : ( $default_required && $has_destination ),
'options_callback' => isset( $destination['options_callback'] ) && is_callable( $destination['options_callback'] )
? $destination['options_callback']
: null,
];
}
/**
* Normalize provider-specific form settings.
*
* @param array $fields Raw fields.
* @return array
*/
private static function normalize_settings_fields( $fields ) {
if ( ! is_array( $fields ) ) {
return [];
}
$out = [];
foreach ( $fields as $field ) {
if ( ! is_array( $field ) ) {
continue;
}
$key = isset( $field['key'] ) ? preg_replace( '/[^A-Za-z0-9_]/', '', (string) $field['key'] ) : '';
if ( '' === $key || empty( $field['label'] ) ) {
continue;
}
$out[] = [
'key' => $key,
'label' => sanitize_text_field( (string) $field['label'] ),
'type' => isset( $field['type'] ) ? sanitize_key( $field['type'] ) : 'text',
'help' => isset( $field['help'] ) ? sanitize_text_field( (string) $field['help'] ) : '',
'placeholder' => isset( $field['placeholder'] ) ? sanitize_text_field( (string) $field['placeholder'] ) : '',
'required' => ! array_key_exists( 'required', $field ) || ! empty( $field['required'] ),
];
}
return $out;
}
/**
* Normalize field mapping support.
*
* @param array $field_map Raw definition.
* @return array
*/
private static function normalize_field_map( $field_map ) {
$field_map = is_array( $field_map ) ? $field_map : [];
$callback = isset( $field_map['options_callback'] ) && is_callable( $field_map['options_callback'] )
? $field_map['options_callback']
: null;
return [
'enabled' => ! empty( $field_map['enabled'] ) || is_callable( $callback ),
'label' => isset( $field_map['label'] ) ? sanitize_text_field( (string) $field_map['label'] ) : __( 'Fields', 'generateblocks-pro' ),
'empty_label' => isset( $field_map['empty_label'] ) ? sanitize_text_field( (string) $field_map['empty_label'] ) : __( 'Don\'t map', 'generateblocks-pro' ),
'refresh_label' => isset( $field_map['refresh_label'] ) ? sanitize_text_field( (string) $field_map['refresh_label'] ) : __( 'Refresh fields', 'generateblocks-pro' ),
'depends_on_destination' => ! empty( $field_map['depends_on_destination'] ),
'options_callback' => $callback,
];
}
/**
* Normalize options into { label, value } rows.
*
* @param mixed $options Raw options.
* @return array
*/
public static function normalize_options( $options ) {
if ( ! is_array( $options ) ) {
return [];
}
$out = [];
foreach ( $options as $option ) {
if ( is_array( $option ) ) {
$value = $option['value'] ?? ( $option['id'] ?? ( $option['tag'] ?? '' ) );
$label = $option['label'] ?? ( $option['name'] ?? ( $option['tag'] ?? $value ) );
$count = $option['count'] ?? null;
} else {
$value = $option;
$label = $option;
$count = null;
}
if ( is_array( $value ) || is_object( $value ) || is_array( $label ) || is_object( $label ) ) {
continue;
}
$row = [
'value' => sanitize_text_field( (string) $value ),
'label' => sanitize_text_field( (string) $label ),
];
if ( null !== $count && is_numeric( $count ) ) {
$row['count'] = (int) $count;
}
$out[] = $row;
}
return $out;
}
/**
* Get a registered provider definition.
*
* @param string $id Provider ID.
* @return array|null
*/
public static function get_provider( $id ) {
return self::$integrations[ sanitize_key( $id ) ] ?? null;
}
/**
* Backward-compatible internal alias for provider lookups.
*
* @param string $id Provider ID.
* @return array|null
*/
public static function get_integration( $id ) {
return self::get_provider( $id );
}
/**
* Get all registered provider definitions.
*
* @return array
*/
public static function get_all() {
return self::$integrations;
}
/**
* Get public provider definitions for the editor.
*
* Callback references, define names, and other server-only data are omitted.
*
* @return array
*/
public static function get_public_definitions() {
return array_values(
array_map(
[ __CLASS__, 'to_public_definition' ],
self::$integrations
)
);
}
/**
* Convert a provider definition into the public editor shape.
*
* @param array $integration Provider definition.
* @return array
*/
private static function to_public_definition( $integration ) {
return [
'id' => $integration['id'],
'label' => $integration['label'],
'type' => $integration['type'],
'help' => $integration['help'],
'connection' => [
'type' => $integration['connection']['type'],
'fields' => array_map(
function ( $field ) {
return [
'key' => $field['key'],
'label' => $field['label'],
'type' => $field['type'],
'help' => $field['help'],
'placeholder' => $field['placeholder'],
'required' => $field['required'],
'testRequired' => $field['test_required'],
];
},
$integration['connection']['fields']
),
],
'destination' => [
'label' => $integration['destination']['label'],
'emptyLabel' => $integration['destination']['empty_label'],
'refreshLabel' => $integration['destination']['refresh_label'],
'required' => $integration['destination']['required'],
'hasOptions' => is_callable( $integration['destination']['options_callback'] ),
],
'secondaryDestination' => [
'label' => $integration['secondary_destination']['label'],
'emptyLabel' => $integration['secondary_destination']['empty_label'],
'refreshLabel' => $integration['secondary_destination']['refresh_label'],
'required' => $integration['secondary_destination']['required'],
'hasOptions' => is_callable( $integration['secondary_destination']['options_callback'] ),
],
'settingsFields' => array_map(
function ( $field ) {
return [
'key' => $field['key'],
'label' => $field['label'],
'type' => $field['type'],
'help' => $field['help'],
'placeholder' => $field['placeholder'],
'required' => $field['required'],
];
},
$integration['settings_fields']
),
'fieldMap' => [
'enabled' => $integration['field_map']['enabled'],
'label' => $integration['field_map']['label'],
'emptyLabel' => $integration['field_map']['empty_label'],
'refreshLabel' => $integration['field_map']['refresh_label'],
'dependsOnDestination' => $integration['field_map']['depends_on_destination'],
'hasOptions' => is_callable( $integration['field_map']['options_callback'] ),
],
'supportsNameField' => $integration['supports_name_field'],
'supportsDoubleOptin' => $integration['supports_double_optin'],
'defaultDoubleOptin' => $integration['default_double_optin'],
'canSubscribe' => is_callable( $integration['subscribe_callback'] ),
];
}
/**
* Register an API key for a service.
*
* @param string $service The service identifier.
* @param string $api_key The API key.
*/
public static function set( $service, $api_key ) {
$service = sanitize_key( $service );
self::$keys[ $service ] = $api_key;
if ( method_exists( 'GenerateBlocks_Pro_Form_Integration_Rest', 'clear_service_cache' ) ) {
GenerateBlocks_Pro_Form_Integration_Rest::clear_service_cache( $service );
}
}
/**
* Get a registered API key.
*
* @param string $service The service identifier.
* @return string The API key, or empty string if not registered.
*/
public static function get( $service ) {
return self::$keys[ sanitize_key( $service ) ] ?? '';
}
}
/**
* Register an email integration API key.
*
* Call this inside the 'generateblocks_form_register_email_integrations' hook:
*
* add_action( 'generateblocks_form_register_email_integrations', function() {
* generateblocks_pro_set_email_integration( 'mailchimp', 'your-api-key' );
* } );
*
* @param string $service The service identifier.
* @param string $api_key The API key.
*/
function generateblocks_pro_set_email_integration( $service, $api_key ) {
GenerateBlocks_Pro_Form_Integration_Registry::set( $service, $api_key );
}
/**
* Register a form integration provider.
*
* @param array $args Provider definition.
* @return bool True when registered.
*/
function generateblocks_pro_register_form_integration( $args ) {
return GenerateBlocks_Pro_Form_Integration_Registry::register( $args );
}
@@ -0,0 +1,842 @@
<?php
/**
* REST endpoints for form integrations.
*
* Exposes registered provider metadata, connection management, and provider
* option lists to the form editor. Connection details stay server-side.
*
* API key storage strategy:
*
* Keys are stored as plain text in the options table, protected by the
* forms-manage capability on save/delete endpoints. We intentionally do not
* encrypt these keys because any encryption key must live on the same server.
* The real security boundary is capability access to these endpoints.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Form integration REST class.
*/
class GenerateBlocks_Pro_Form_Integration_Rest extends GenerateBlocks_Pro_Singleton {
/**
* Per-request cache for resolved connection settings.
*
* @var array<string, string>
*/
private static $connection_cache = [];
/**
* Initialize routes.
*/
public function init() {
add_action( 'rest_api_init', [ $this, 'register_routes' ] );
}
/**
* Register REST routes.
*/
public function register_routes() {
$namespace = 'generateblocks-pro/v1';
register_rest_route(
$namespace,
'/integrations/(?P<service>[a-z0-9_-]+)',
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'save_settings' ],
'permission_callback' => [ $this, 'admin_permission' ],
'args' => [
'service' => [
'required' => true,
'validate_callback' => [ $this, 'validate_service' ],
],
'settings' => [
'required' => false,
'type' => 'object',
],
],
]
);
register_rest_route(
$namespace,
'/integrations/(?P<service>[a-z0-9_-]+)',
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => [ $this, 'delete_settings' ],
'permission_callback' => [ $this, 'admin_permission' ],
'args' => [
'service' => [
'required' => true,
'validate_callback' => [ $this, 'validate_service' ],
],
],
]
);
register_rest_route(
$namespace,
'/integrations/(?P<service>[a-z0-9_-]+)/test',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'test_connection' ],
'permission_callback' => [ $this, 'admin_permission' ],
'args' => [
'service' => [
'required' => true,
'validate_callback' => [ $this, 'validate_service' ],
],
'settings' => [
'required' => false,
'type' => 'object',
],
],
]
);
register_rest_route(
$namespace,
'/integrations/(?P<service>[a-z0-9_-]+)/destinations',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'fetch_destinations' ],
'permission_callback' => [ $this, 'admin_permission' ],
'args' => [
'service' => [
'required' => true,
'validate_callback' => [ $this, 'validate_service' ],
],
'nocache' => [
'required' => false,
'type' => 'boolean',
],
],
]
);
register_rest_route(
$namespace,
'/integrations/(?P<service>[a-z0-9_-]+)/secondary-destinations',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'fetch_secondary_destinations' ],
'permission_callback' => [ $this, 'admin_permission' ],
'args' => [
'service' => [
'required' => true,
'validate_callback' => [ $this, 'validate_service' ],
],
'nocache' => [
'required' => false,
'type' => 'boolean',
],
],
]
);
register_rest_route(
$namespace,
'/integrations/(?P<service>[a-z0-9_-]+)/fields',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'fetch_fields' ],
'permission_callback' => [ $this, 'admin_permission' ],
'args' => [
'service' => [
'required' => true,
'validate_callback' => [ $this, 'validate_service' ],
],
'nocache' => [
'required' => false,
'type' => 'boolean',
],
],
]
);
register_rest_route(
$namespace,
'/integrations',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_integrations' ],
'permission_callback' => [ $this, 'admin_permission' ],
]
);
}
/**
* Save provider connection settings.
*
* @param WP_REST_Request $request The request.
* @return WP_REST_Response
*/
public function save_settings( WP_REST_Request $request ) {
$service = $request->get_param( 'service' );
$settings = $this->sanitize_connection_settings(
$service,
$request->get_param( 'settings' )
);
$effective_settings = $this->get_effective_connection_settings( $service, $settings );
$validation = $this->validate_connection_settings( $service, $effective_settings );
if ( is_wp_error( $validation ) ) {
return new WP_REST_Response(
[
'success' => false,
'message' => $validation->get_error_message(),
],
200
);
}
$integrations = get_option( 'generateblocks_pro_form_integrations', [] );
$integrations = is_array( $integrations ) ? $integrations : [];
$current = isset( $integrations[ $service ] ) && is_array( $integrations[ $service ] )
? $integrations[ $service ]
: [];
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$connection = $provider['connection'] ?? [];
foreach ( $connection['fields'] ?? [] as $field ) {
$key = $field['key'];
if (
array_key_exists( $key, $settings )
&& ! in_array( $this->get_connection_field_source( $service, $field ), [ 'define', 'code' ], true )
) {
$current[ $key ] = $settings[ $key ];
}
}
$integrations[ $service ] = $current;
update_option( 'generateblocks_pro_form_integrations', $integrations, false );
self::clear_service_cache( $service );
return new WP_REST_Response( [ 'success' => true ], 200 );
}
/**
* Delete provider connection settings.
*
* @param WP_REST_Request $request The request.
* @return WP_REST_Response
*/
public function delete_settings( WP_REST_Request $request ) {
$service = $request->get_param( 'service' );
$integrations = get_option( 'generateblocks_pro_form_integrations', [] );
$integrations = is_array( $integrations ) ? $integrations : [];
unset( $integrations[ $service ] );
update_option( 'generateblocks_pro_form_integrations', $integrations, false );
self::clear_service_cache( $service );
return new WP_REST_Response( [ 'success' => true ], 200 );
}
/**
* Test a service connection with provided settings.
*
* @param WP_REST_Request $request The request.
* @return WP_REST_Response
*/
public function test_connection( WP_REST_Request $request ) {
$service = $request->get_param( 'service' );
$settings = $this->sanitize_connection_settings(
$service,
$request->get_param( 'settings' )
);
$effective_settings = $this->get_effective_connection_settings( $service, $settings );
$validation = $this->validate_connection_settings( $service, $effective_settings, true );
if ( is_wp_error( $validation ) ) {
return new WP_REST_Response(
[
'success' => false,
'message' => $validation->get_error_message(),
],
200
);
}
$result = $this->test_service( $service, $effective_settings );
if ( is_wp_error( $result ) ) {
return new WP_REST_Response(
[
'success' => false,
'message' => $result->get_error_message(),
],
200
);
}
return new WP_REST_Response(
[
'success' => true,
'message' => __( 'Connected successfully.', 'generateblocks-pro' ),
],
200
);
}
/**
* Fetch primary destination options for a provider.
*
* @param WP_REST_Request $request The request.
* @return WP_REST_Response
*/
public function fetch_destinations( WP_REST_Request $request ) {
return $this->fetch_provider_options( $request, 'destination' );
}
/**
* Fetch secondary destination options for a provider.
*
* @param WP_REST_Request $request The request.
* @return WP_REST_Response
*/
public function fetch_secondary_destinations( WP_REST_Request $request ) {
return $this->fetch_provider_options( $request, 'secondary_destination' );
}
/**
* Fetch field-map options for a provider.
*
* @param WP_REST_Request $request The request.
* @return WP_REST_Response
*/
public function fetch_fields( WP_REST_Request $request ) {
return $this->fetch_provider_options( $request, 'field_map' );
}
/**
* Fetch options from a provider callback.
*
* @param WP_REST_Request $request The request.
* @param string $key Provider option key.
* @return WP_REST_Response
*/
private function fetch_provider_options( WP_REST_Request $request, $key ) {
$service = $request->get_param( 'service' );
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$config = $provider[ $key ] ?? [];
$callback = $config['options_callback'] ?? null;
if ( ! is_callable( $callback ) ) {
return new WP_REST_Response(
[
'success' => true,
'options' => [],
],
200
);
}
$connection_ready = $this->connection_ready_for_options( $service );
if ( is_wp_error( $connection_ready ) ) {
return new WP_REST_Response(
[
'success' => false,
'message' => $connection_ready->get_error_message(),
],
200
);
}
if ( ! $connection_ready ) {
return new WP_REST_Response(
[
'success' => false,
'message' => __( 'Integration is not connected.', 'generateblocks-pro' ),
],
200
);
}
$settings = $this->get_option_request_settings( $request, $service );
$cache_key = 'gb_form_options_' . $service . '_' . $key . '_' . md5(
wp_json_encode( [ self::connection_cache_hash( $service ), $settings ] )
);
$nocache = (bool) $request->get_param( 'nocache' );
$cached = $nocache ? false : get_transient( $cache_key );
if ( false !== $cached ) {
return new WP_REST_Response(
[
'success' => true,
'options' => $cached,
],
200
);
}
$options = call_user_func( $callback, $settings, $request, $service );
if ( is_wp_error( $options ) ) {
return new WP_REST_Response(
[
'success' => false,
'message' => $options->get_error_message(),
],
200
);
}
$options = GenerateBlocks_Pro_Form_Integration_Registry::normalize_options( $options );
set_transient( $cache_key, $options, 5 * MINUTE_IN_SECONDS );
return new WP_REST_Response(
[
'success' => true,
'options' => $options,
],
200
);
}
/**
* Get public integration definitions and connection state.
*
* @return WP_REST_Response
*/
public function get_integrations() {
$connected = [];
foreach ( GenerateBlocks_Pro_Form_Integration_Registry::get_all() as $service => $provider ) {
$connected[ $service ] = $this->get_connection_state( $service, $provider );
}
return new WP_REST_Response(
[
'success' => true,
'integrations' => GenerateBlocks_Pro_Form_Integration_Registry::get_public_definitions(),
'connected' => $connected,
],
200
);
}
/**
* Get the API key for a service.
*
* @param string $service The service name.
* @return string The API key, or empty string if not configured.
*/
public static function get_api_key_for( $service ) {
return self::get_connection_setting_for( $service, 'api_key' );
}
/**
* Get the site key for a service.
*
* @param string $service The service name.
* @return string The site key, or empty string if not configured.
*/
public static function get_site_key_for( $service ) {
return self::get_connection_setting_for( $service, 'site_key' );
}
/**
* Get a connection setting for a service.
*
* Priority chain:
* 1. wp-config.php define registered on the field.
* 2. In-memory registered key for api_key.
* 3. Database option saved via admin UI.
*
* @param string $service Service ID.
* @param string $key Setting key.
* @return string
*/
public static function get_connection_setting_for( $service, $key ) {
$service = sanitize_key( $service );
$key = preg_replace( '/[^A-Za-z0-9_]/', '', (string) $key );
$cache_key = $service . ':' . $key;
if ( array_key_exists( $cache_key, self::$connection_cache ) ) {
return self::$connection_cache[ $cache_key ];
}
$value = self::resolve_connection_setting( $service, $key );
self::$connection_cache[ $cache_key ] = $value;
return $value;
}
/**
* Resolve a connection setting without cache.
*
* @param string $service Service ID.
* @param string $key Setting key.
* @return string
*/
private static function resolve_connection_setting( $service, $key ) {
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$connection = $provider['connection'] ?? [];
$define = '';
foreach ( $connection['fields'] ?? [] as $field ) {
if ( $field['key'] === $key ) {
$define = $field['define'];
break;
}
}
if ( $define && defined( $define ) ) {
$value = (string) constant( $define );
if ( '' !== trim( $value ) ) {
return $value;
}
}
if ( 'api_key' === $key ) {
$registered = GenerateBlocks_Pro_Form_Integration_Registry::get( $service );
if ( ! empty( $registered ) ) {
return (string) $registered;
}
}
$integrations = get_option( 'generateblocks_pro_form_integrations', [] );
$integrations = is_array( $integrations ) ? $integrations : [];
return isset( $integrations[ $service ][ $key ] )
? (string) $integrations[ $service ][ $key ]
: '';
}
/**
* Sanitize connection settings against registered connection fields.
*
* @param string $service Service ID.
* @param array $settings Raw settings.
* @return array
*/
private function sanitize_connection_settings( $service, $settings ) {
if ( ! is_array( $settings ) ) {
return [];
}
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$fields = $provider['connection']['fields'] ?? [];
$out = [];
foreach ( $fields as $field ) {
$key = $field['key'];
if ( isset( $settings[ $key ] ) && ! is_array( $settings[ $key ] ) && ! is_object( $settings[ $key ] ) ) {
$value = sanitize_text_field( (string) $settings[ $key ] );
if ( '' !== trim( $value ) ) {
$out[ $key ] = $value;
}
}
}
return $out;
}
/**
* Merge submitted settings with currently resolved settings.
*
* Blank modal fields mean "leave the existing code/DB value alone".
*
* @param string $service Service ID.
* @param array $settings Sanitized submitted settings.
* @return array
*/
private function get_effective_connection_settings( $service, $settings ) {
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$fields = $provider['connection']['fields'] ?? [];
$out = [];
foreach ( $fields as $field ) {
$key = $field['key'];
$out[ $key ] = array_key_exists( $key, $settings )
? $settings[ $key ]
: self::get_connection_setting_for( $service, $key );
}
return $out;
}
/**
* Validate sanitized connection settings against required fields.
*
* @param string $service Service ID.
* @param array $settings Sanitized settings.
* @param bool $for_test Whether this validation is for a connection test.
* @return true|WP_Error
*/
private function validate_connection_settings( $service, $settings, $for_test = false ) {
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$fields = $provider['connection']['fields'] ?? [];
foreach ( $fields as $field ) {
$required_key = $for_test ? 'test_required' : 'required';
if ( empty( $field[ $required_key ] ) ) {
continue;
}
$key = $field['key'];
$value = isset( $settings[ $key ] ) ? trim( (string) $settings[ $key ] ) : '';
if ( '' === $value ) {
return new WP_Error(
'missing_connection_setting',
sprintf(
/* translators: %s: connection setting label */
__( '%s is required.', 'generateblocks-pro' ),
$field['label']
)
);
}
}
return true;
}
/**
* Build sanitized settings from option route query params.
*
* @param WP_REST_Request $request The request.
* @param string $service Service ID.
* @return array
*/
private function get_option_request_settings( WP_REST_Request $request, $service ) {
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$params = $request->get_params();
$allowed = [
'destinationId' => true,
'secondaryDestinationId' => true,
];
$out = [];
foreach ( $provider['connection']['fields'] ?? [] as $field ) {
$allowed[ $field['key'] ] = true;
}
foreach ( $params as $key => $value ) {
$key = (string) $key;
if ( ! isset( $allowed[ $key ] ) || is_array( $value ) || is_object( $value ) ) {
continue;
}
$out[ $key ] = sanitize_text_field( (string) $value );
}
ksort( $out );
return $out;
}
/**
* Test a specific provider connection.
*
* @param string $service Service ID.
* @param array $settings Connection settings.
* @return true|WP_Error
*/
private function test_service( $service, $settings ) {
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$callback = $provider['connection']['test_callback'] ?? null;
if ( ! is_callable( $callback ) ) {
return true;
}
return call_user_func( $callback, $settings, $service );
}
/**
* Determine whether a provider is connected enough to fetch options.
*
* @param string $service Service ID.
* @return bool|WP_Error
*/
private function connection_ready_for_options( $service ) {
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$connection = $provider['connection'] ?? [];
if ( empty( $connection ) || 'none' === $connection['type'] ) {
return true;
}
if ( is_callable( $connection['connected_callback'] ) ) {
return call_user_func( $connection['connected_callback'], $service );
}
foreach ( $connection['fields'] ?? [] as $field ) {
if ( ! empty( $field['required'] ) && '' === self::get_connection_setting_for( $service, $field['key'] ) ) {
return false;
}
}
return true;
}
/**
* Get connection state for the editor.
*
* @param string $service Service ID.
* @param array $provider Provider definition.
* @return array
*/
private function get_connection_state( $service, $provider ) {
$connection = $provider['connection'] ?? [];
$callback_result = null;
$source = '';
$fields = [];
$public_fields = [];
$connected = true;
if ( empty( $connection ) || 'none' === $connection['type'] ) {
return [
'connected' => true,
'source' => '',
'fields' => [],
];
}
if ( is_callable( $connection['connected_callback'] ) ) {
$callback_result = call_user_func( $connection['connected_callback'], $service );
$connected = ! is_wp_error( $callback_result ) && (bool) $callback_result;
}
foreach ( $connection['fields'] ?? [] as $field ) {
$value = self::get_connection_setting_for( $service, $field['key'] );
$field_key = $field['key'];
$field_state = [
'configured' => '' !== $value,
'source' => $this->get_connection_field_source( $service, $field ),
];
$fields[ $field_key ] = $field_state;
if ( ! empty( $field['public'] ) && '' !== $value ) {
$public_fields[ $field_key ] = $value;
}
if ( '' === $source && '' !== $field_state['source'] ) {
$source = $field_state['source'];
}
if ( ! empty( $field['required'] ) && '' === $value ) {
$connected = false;
}
}
$state = [
'connected' => $connected,
'source' => $source,
'fields' => $fields,
];
if ( is_wp_error( $callback_result ) ) {
$state['message'] = $callback_result->get_error_message();
}
if ( ! empty( $public_fields ) ) {
$state['publicFields'] = $public_fields;
}
return $state;
}
/**
* Get the source for a connection field.
*
* @param string $service Service ID.
* @param array $field Connection field.
* @return string
*/
private function get_connection_field_source( $service, $field ) {
$define = $field['define'];
if ( $define && defined( $define ) && '' !== trim( (string) constant( $define ) ) ) {
return 'define';
}
if ( 'api_key' === $field['key'] && ! empty( GenerateBlocks_Pro_Form_Integration_Registry::get( $service ) ) ) {
return 'code';
}
$integrations = get_option( 'generateblocks_pro_form_integrations', [] );
$integrations = is_array( $integrations ) ? $integrations : [];
if ( ! empty( $integrations[ $service ][ $field['key'] ] ) ) {
return 'db';
}
return '';
}
/**
* Build a hash of current connection settings for option-cache keys.
*
* @param string $service Service ID.
* @return string
*/
private static function connection_cache_hash( $service ) {
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$values = [];
foreach ( $provider['connection']['fields'] ?? [] as $field ) {
$values[ $field['key'] ] = self::get_connection_setting_for( $service, $field['key'] );
}
return md5( wp_json_encode( $values ) );
}
/**
* Clear the per-request connection cache for a service.
*
* @param string $service Service ID.
*/
public static function clear_service_cache( $service ) {
$service = sanitize_key( $service );
foreach ( array_keys( self::$connection_cache ) as $key ) {
if ( 0 === strpos( $key, $service . ':' ) ) {
unset( self::$connection_cache[ $key ] );
}
}
}
/**
* Validate that the service parameter is registered.
*
* @param string $value The service name.
* @return bool
*/
public function validate_service( $value ) {
return (bool) GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $value );
}
/**
* Permission check for all integration routes.
*
* @return bool
*/
public function admin_permission() {
return current_user_can( 'manage_options' );
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
<?php
/**
* Editor preview REST endpoint.
*
* Returns server-rendered form HTML for use inside the Form block's
* in-editor preview. Uses the same render helper as the public runtime
* so there is no JS/PHP drift — what you see here is what visitors get.
*
* Permission: users with the baseline `use` cap can hit the route, and the
* render helper then allows published forms for those users while requiring
* edit access on the specific form for draft/private previews.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Form preview REST class.
*/
class GenerateBlocks_Pro_Form_Preview_Rest extends GenerateBlocks_Pro_Singleton {
/**
* Initialize.
*/
public function init() {
add_action( 'rest_api_init', [ $this, 'register_routes' ] );
}
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
'generateblocks-pro/v1',
'/forms/(?P<id>\d+)/preview',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'render_preview' ],
'permission_callback' => [ $this, 'can_preview' ],
'args' => [
'id' => [
'required' => true,
'type' => 'integer',
'sanitize_callback' => 'absint',
],
],
]
);
}
/**
* Render the form HTML for an editor preview.
*
* Returns 200 with `{ html }` on success, 200 with `{ success:false, message }`
* on failure. 200 is intentional — this is an editor convenience endpoint,
* not a state change, and the editor shows the message inline.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response
*/
public function render_preview( WP_REST_Request $request ) {
$form_id = absint( $request->get_param( 'id' ) );
$html = GenerateBlocks_Pro_Form_Render::render( $form_id, 'editor' );
if ( is_wp_error( $html ) ) {
return new WP_REST_Response(
[
'success' => false,
'message' => $html->get_error_message(),
],
200
);
}
return new WP_REST_Response(
[
'success' => true,
'html' => $html,
],
200
);
}
/**
* Permission callback.
*
* Individual form permission checks happen inside the render helper;
* here we only gate on the baseline "can use forms at all" cap to stop
* anonymous REST enumeration of preview URLs.
*
* @return bool
*/
public function can_preview() {
return GenerateBlocks_Pro_Form_Post_Type::current_user_can( 'use' );
}
}
@@ -0,0 +1,789 @@
<?php
/**
* Form submission processor.
*
* Reads form config from the `gblocks_form` post meta and derives the field
* contract from the form post's saved block content at submit time. Running
* through a single authoritative record by form post ID means template
* parts, widgets, reusable blocks, and FSE templates all submit identically
* with no host-page ambiguity or cached schema drift.
*
* Order:
* 1. Load config by $form_id (post ID).
* 2. Derive the field schema from saved form content.
* 3. Enforce server-side Turnstile requirement if configured.
* 4. Run conditional-visibility convergence against the derived schema.
* 5. Sanitize fields through the server-derived schema.
* 6. Store a bounded submission record when the form opts in.
* 7. Run registered actions and update the stored submission status.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Form processor class.
*/
class GenerateBlocks_Pro_Form_Processor {
/**
* Process a form submission.
*
* @param int $form_id gblocks_form post ID.
* @param int $post_id Host page ID where the form was submitted.
* @param array $raw_data Raw submitted field data (field_name => value).
* @param array $context Request context (post_id, page_url, turnstile_token?).
* @return true|WP_Error True on success, WP_Error on failure.
*/
public static function process( $form_id, $post_id, $raw_data, $context = [] ) {
$form_id = absint( $form_id );
if ( ! $form_id ) {
return new WP_Error( 'invalid_form_id', 'Invalid form ID.' );
}
$form = get_post( $form_id );
if (
! $form
|| GenerateBlocks_Pro_Form_Post_Type::POST_TYPE !== $form->post_type
|| 'publish' !== $form->post_status
) {
return new WP_Error( 'form_not_found', 'Form not found.' );
}
$meta = get_post_meta( $form_id, GenerateBlocks_Pro_Form_Post_Type::META_KEY, true );
$meta = is_array( $meta ) ? $meta : [];
$form_settings = wp_parse_args(
isset( $meta['config'] ) && is_array( $meta['config'] )
? $meta['config']
: [],
GenerateBlocks_Pro_Form_Post_Type::default_form_config()
);
$actions = isset( $form_settings['actions'] ) && is_array( $form_settings['actions'] )
? $form_settings['actions']
: [];
if ( empty( $actions ) ) {
$public_message = self::incomplete_configuration_public_message();
return new WP_Error(
'form_actions_empty',
$public_message,
[
'public_message' => $public_message,
]
);
}
if ( ! class_exists( 'GenerateBlocks_Pro_Form_Schema_Builder' ) ) {
return new WP_Error( 'form_schema_unavailable', 'Form schema unavailable.' );
}
$field_schema_list = GenerateBlocks_Pro_Form_Schema_Builder::get_instance()->build_schema_from_content( $form->post_content );
if ( is_wp_error( $field_schema_list ) ) {
return $field_schema_list;
}
if ( empty( $field_schema_list ) ) {
return new WP_Error( 'form_schema_empty', 'Form has no fields.' );
}
// Server-side Turnstile enforcement: require a token only when the
// integration is fully configured (both secret and site key present).
// A secret-only configuration can't render the widget, so the token
// would never arrive and every submission would be rejected — the
// editor UI explicitly promises fail-open until both keys are set.
if (
! empty( $form_settings['useTurnstile'] )
&& '' !== GenerateBlocks_Pro_Form_Turnstile::get_secret_key()
&& '' !== GenerateBlocks_Pro_Form_Turnstile::get_site_key()
&& ! array_key_exists( 'turnstile_token', $context )
) {
return new WP_Error( 'turnstile_required', 'Turnstile verification required.' );
}
// Convert the indexed schema list (order-preserving) into the
// associative map the downstream convergence/sanitizer expect.
$field_schema = [];
foreach ( $field_schema_list as $entry ) {
if ( ! is_array( $entry ) || empty( $entry['name'] ) ) {
continue;
}
$field_schema[ $entry['name'] ] = self::schema_entry_to_map( $entry );
}
// Evaluate conditional visibility server-side (see resolve_hidden_fields
// for the convergence contract). Fields that remain hidden after
// convergence are stripped from both the submitted data and the schema
// so the sanitizer never sees them.
$hidden_fields = self::resolve_hidden_fields( $field_schema, $raw_data );
foreach ( $hidden_fields as $name ) {
unset( $raw_data[ $name ] );
unset( $field_schema[ $name ] );
}
$sanitized = GenerateBlocks_Pro_Form_Sanitizer::sanitize_submission( $raw_data, $field_schema );
if ( is_wp_error( $sanitized ) ) {
return $sanitized;
}
/**
* Filter to validate sanitized form data before actions run.
*
* @since 2.6.0
* @param array $sanitized Sanitized field data.
* @param int $form_id Form post ID.
* @param array $form_settings Form config from meta.
* @param array $context Request context.
*/
$sanitized = apply_filters(
'generateblocks_form_validate_submission',
$sanitized,
$form_id,
$form_settings,
$context
);
if ( is_wp_error( $sanitized ) ) {
return $sanitized;
}
$actions = self::resolve_active_actions( $actions, $form_settings, $field_schema, $sanitized );
$submission_id = false;
if ( ! empty( $form_settings['storeSubmissions'] ) ) {
$submission_id = GenerateBlocks_Pro_Form_Submissions::insert_received(
$form_id,
$post_id,
$sanitized
);
}
$preflight_errors = self::validate_actions_before_execution(
$actions,
$form_settings,
$field_schema_list,
$sanitized
);
if ( ! empty( $preflight_errors ) ) {
return self::handle_action_failures( $form_id, $post_id, $sanitized, $preflight_errors, $submission_id );
}
$errors = [];
$has_blocking_action = self::has_blocking_actions( $actions );
foreach ( $actions as $action_name ) {
if ( ! is_string( $action_name ) ) {
continue;
}
$callback = GenerateBlocks_Pro_Form_Action_Registry::get( $action_name );
if ( ! $callback ) {
if ( $has_blocking_action && self::is_non_blocking_action( $action_name ) ) {
continue;
}
// Unknown/deactivated action: treat as failure so a stored
// submission is marked failed. Silent skip would hide data loss.
$errors[] = 'Unknown action: ' . sanitize_key( $action_name );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log(
'GenerateBlocks Form: unknown action "' . sanitize_key( $action_name )
. '" in form ' . $form_id
);
continue;
}
$result = call_user_func( $callback, $sanitized, $form_settings, $context );
if ( is_wp_error( $result ) ) {
if ( $has_blocking_action && self::is_non_blocking_action( $action_name ) ) {
continue;
}
$errors[] = $result;
}
}
if ( ! empty( $errors ) ) {
return self::handle_action_failures( $form_id, $post_id, $sanitized, $errors, $submission_id );
}
if ( $submission_id ) {
GenerateBlocks_Pro_Form_Submissions::update_status( $submission_id, 'processed', null, $form_id );
}
if ( ! empty( $actions ) ) {
GenerateBlocks_Pro_Form_Post_Type::record_delivery_success( $form_id );
}
return true;
}
/**
* Validate built-in action configuration before any action can run.
*
* This prevents partial delivery when an earlier action has a side effect
* and a later action is guaranteed to fail from missing configuration.
*
* @param array $actions Action names from form config.
* @param array $form_settings Form config from meta.
* @param array $field_schema_list Derived field schema list.
* @param array $sanitized_data Sanitized submitted data.
* @return array Error messages or WP_Error instances.
*/
private static function validate_actions_before_execution( $actions, $form_settings, $field_schema_list, $sanitized_data ) {
$errors = [];
$has_blocking_action = self::has_blocking_actions( $actions );
foreach ( $actions as $action_name ) {
if ( ! is_string( $action_name ) ) {
continue;
}
$action_name = sanitize_key( $action_name );
$callback = GenerateBlocks_Pro_Form_Action_Registry::get( $action_name );
if ( $has_blocking_action && self::is_non_blocking_action( $action_name ) ) {
continue;
}
if ( ! $callback ) {
$errors[] = 'Unknown action: ' . $action_name;
continue;
}
$validation = self::validate_builtin_action_before_execution(
$action_name,
$form_settings,
$field_schema_list,
$sanitized_data
);
if ( is_wp_error( $validation ) ) {
$errors[] = $validation;
}
}
return $errors;
}
/**
* Filter configured actions down to actions active for this submission.
*
* The built-in email signup action can be gated by a single checkbox field
* in contact + signup forms. Other actions always stay active.
*
* @param array $actions Configured action names.
* @param array $form_settings Form config from meta.
* @param array $field_schema Active field schema map.
* @param array $sanitized_data Sanitized submitted data.
* @return array Active action names.
*/
private static function resolve_active_actions( $actions, $form_settings, $field_schema, $sanitized_data ) {
if ( ! is_array( $actions ) ) {
return [];
}
$active = [];
$action_keys = array_map(
static function ( $action_name ) {
return is_string( $action_name ) ? sanitize_key( $action_name ) : '';
},
$actions
);
$is_contact_email_signup = in_array( 'email', $action_keys, true ) && in_array( 'email-signup', $action_keys, true );
foreach ( $actions as $action_name ) {
if ( ! is_string( $action_name ) ) {
continue;
}
if (
$is_contact_email_signup &&
'email-signup' === sanitize_key( $action_name ) &&
! self::email_signup_opt_in_allows_action( $form_settings, $field_schema, $sanitized_data )
) {
continue;
}
$active[] = $action_name;
}
return $active;
}
/**
* Check whether an email signup opt-in field allows signup to run.
*
* Missing opt-in config means the action runs normally. When configured, it
* must point at a visible checkbox field whose sanitized submitted value
* matches that field's checked value.
*
* @param array $form_settings Form config from meta.
* @param array $field_schema Active field schema map.
* @param array $sanitized_data Sanitized submitted data.
* @return bool Whether signup can run.
*/
private static function email_signup_opt_in_allows_action( $form_settings, $field_schema, $sanitized_data ) {
$action_settings = $form_settings['actionSettings']['email-signup'] ?? null;
if ( ! is_array( $action_settings ) ) {
return true;
}
$raw_opt_in_field = $action_settings['optInField'] ?? '';
$opt_in_field = is_scalar( $raw_opt_in_field ) ? sanitize_key( (string) $raw_opt_in_field ) : '';
if ( '' === $opt_in_field ) {
return true;
}
if ( 'checkbox' !== ( $field_schema[ $opt_in_field ]['type'] ?? '' ) ) {
return false;
}
$field = $sanitized_data[ $opt_in_field ] ?? null;
if ( ! is_array( $field ) ) {
return false;
}
$actual = sanitize_text_field( (string) ( $field['value'] ?? '' ) );
$expected = sanitize_text_field( (string) ( $field_schema[ $opt_in_field ]['checkedValue'] ?? 'yes' ) );
return '' !== $actual && $actual === $expected;
}
/**
* Validate configuration/data for built-in actions with known prerequisites.
*
* Third-party actions still run through the normal callback result handling.
*
* @param string $action_name Sanitized action name.
* @param array $form_settings Form config from meta.
* @param array $field_schema_list Derived field schema list.
* @param array $sanitized_data Sanitized submitted data.
* @return true|WP_Error
*/
private static function validate_builtin_action_before_execution( $action_name, $form_settings, $field_schema_list, $sanitized_data ) {
switch ( $action_name ) {
case 'email':
if ( class_exists( 'GenerateBlocks_Pro_Form_Action_Email' ) ) {
return GenerateBlocks_Pro_Form_Action_Email::validate_settings( $form_settings );
}
break;
case 'webhook':
if ( class_exists( 'GenerateBlocks_Pro_Form_Action_Webhook' ) ) {
return GenerateBlocks_Pro_Form_Action_Webhook::validate_settings( $form_settings );
}
break;
case 'confirmation-email':
if ( class_exists( 'GenerateBlocks_Pro_Form_Action_Confirmation_Email' ) ) {
$settings_check = GenerateBlocks_Pro_Form_Action_Confirmation_Email::validate_settings(
$form_settings,
$field_schema_list
);
if ( is_wp_error( $settings_check ) ) {
return $settings_check;
}
return GenerateBlocks_Pro_Form_Action_Confirmation_Email::validate_submission(
$sanitized_data,
$form_settings
);
}
break;
case 'email-signup':
if ( class_exists( 'GenerateBlocks_Pro_Form_Action_Email_Signup' ) ) {
$settings = isset( $form_settings['actionSettings'][ GenerateBlocks_Pro_Form_Action_Email_Signup::ACTION ] )
? $form_settings['actionSettings'][ GenerateBlocks_Pro_Form_Action_Email_Signup::ACTION ]
: [];
$settings_check = GenerateBlocks_Pro_Form_Action_Email_Signup::validate_settings(
$settings,
$field_schema_list
);
if ( is_wp_error( $settings_check ) ) {
return self::with_public_failure_message(
$settings_check,
self::incomplete_configuration_public_message()
);
}
return GenerateBlocks_Pro_Form_Action_Email_Signup::validate_submission(
$sanitized_data,
$settings
);
}
break;
}
return true;
}
/**
* Check whether an action is secondary and should not fail other delivery.
*
* @param string $action_name Action name.
* @return bool
*/
private static function is_non_blocking_action( $action_name ) {
return 'confirmation-email' === sanitize_key( (string) $action_name );
}
/**
* Check whether the action list includes at least one primary delivery action.
*
* @param array $actions Action names.
* @return bool
*/
private static function has_blocking_actions( $actions ) {
foreach ( $actions as $action_name ) {
if ( is_string( $action_name ) && ! self::is_non_blocking_action( $action_name ) ) {
return true;
}
}
return false;
}
/**
* Persist failed action delivery and return a public-safe error.
*
* @param int $form_id gblocks_form post ID.
* @param int $post_id Host page ID.
* @param array $sanitized_data Sanitized submitted data.
* @param array $errors Action error messages or WP_Error instances.
* @param int $submission_id Stored submission ID, if storage succeeded.
* @return WP_Error
*/
private static function handle_action_failures( $form_id, $post_id, $sanitized_data, $errors, $submission_id = 0 ) {
$error_messages = self::normalize_failure_messages( $errors );
unset( $post_id, $sanitized_data );
if ( $submission_id ) {
GenerateBlocks_Pro_Form_Submissions::update_status( $submission_id, 'failed', $error_messages, $form_id );
}
GenerateBlocks_Pro_Form_Post_Type::record_delivery_failure( $form_id, $errors );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log(
'GenerateBlocks Form Error [' . $form_id . ']: ' . implode( '; ', $error_messages )
);
$public_message = self::get_public_failure_message( $errors );
if ( '' !== $public_message ) {
return new WP_Error(
'action_failed',
$public_message,
[
'public_message' => $public_message,
]
);
}
return new WP_Error( 'action_failed', 'One or more actions failed.' );
}
/**
* Public-safe message for forms that are published before setup is complete.
*
* @return string
*/
private static function incomplete_configuration_public_message() {
return __( 'This form is not fully configured. Please contact the site administrator.', 'generateblocks-pro' );
}
/**
* Attach a public-safe message while preserving the admin-facing error.
*
* @param WP_Error $error Original error.
* @param string $public_message Message safe to show to submitters.
* @return WP_Error
*/
private static function with_public_failure_message( WP_Error $error, $public_message ) {
$existing_data = $error->get_error_data();
$data = is_array( $existing_data ) ? $existing_data : [];
if ( null !== $existing_data && ! is_array( $existing_data ) ) {
$data['private_data'] = $existing_data;
}
$data['public_message'] = sanitize_text_field( (string) $public_message );
$error->add_data( $data, $error->get_error_code() );
return $error;
}
/**
* Convert mixed action failures to admin-facing strings.
*
* @param array $errors Action error messages or WP_Error instances.
* @return array
*/
private static function normalize_failure_messages( $errors ) {
$messages = [];
foreach ( $errors as $error ) {
if ( is_wp_error( $error ) ) {
$error = $error->get_error_message();
}
if ( is_string( $error ) && '' !== $error ) {
$messages[] = $error;
}
}
return $messages;
}
/**
* Read the first explicitly public-safe failure message.
*
* @param array $errors Action error messages or WP_Error instances.
* @return string
*/
private static function get_public_failure_message( $errors ) {
foreach ( $errors as $error ) {
if ( ! is_wp_error( $error ) ) {
continue;
}
$data = $error->get_error_data();
if ( ! is_array( $data ) || empty( $data['public_message'] ) || ! is_string( $data['public_message'] ) ) {
continue;
}
return sanitize_text_field( $data['public_message'] );
}
return '';
}
/**
* Resolve which fields remain hidden after conditional-visibility convergence.
*
* Contract: every field with non-empty `conditions` starts hidden. On each
* pass we build an effective data map where currently-hidden fields
* contribute empty values, then calculate the next visibility state for all
* conditional fields from that snapshot. Applying changes after the full
* pass matches the frontend's fixed-point model and avoids DOM/schema order
* deciding which fields submit.
*
* Matching the frontend's start-hidden model here means cascading
* conditions (A reveals B reveals C) resolve the same way regardless
* of schema order, and a browser that disables JS submits exactly the
* fields the server expects.
*
* Public so unit tests can exercise the convergence in isolation.
*
* @param array $field_schema Associative field_name => config map.
* @param array $raw_data Raw user-submitted data.
* @return array Names of fields that remain hidden after convergence.
*/
public static function resolve_hidden_fields( $field_schema, $raw_data ) {
$visibility = [];
foreach ( $field_schema as $name => $config ) {
$visibility[ $name ] = empty( $config['conditions'] );
}
$passes = 0;
$max_passes = max( 10, count( $visibility ) + 1 );
do {
$changed = false;
$effective_data = $raw_data;
foreach ( $visibility as $field_name => $is_visible ) {
if ( ! $is_visible ) {
$effective_data[ $field_name ] = '';
}
}
$next_visibility = $visibility;
foreach ( $field_schema as $name => $config ) {
if ( empty( $config['conditions'] ) ) {
$next_visibility[ $name ] = true;
continue;
}
$next_visibility[ $name ] = self::evaluate_conditions( $config['conditions'], $field_schema, $effective_data );
}
foreach ( $next_visibility as $name => $is_visible ) {
if ( ( $visibility[ $name ] ?? false ) !== $is_visible ) {
$changed = true;
break;
}
}
$visibility = $next_visibility;
$passes++;
} while ( $changed && $passes < $max_passes );
// Hitting the cap with state still changing means a circular or
// pathologically deep condition graph. Log so an admin debugging "field
// never reveals" has a signal — conditional fields fail closed, which is
// the safe default for submission validation.
if ( $changed ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log(
'GenerateBlocks Form: conditional visibility did not converge in '
. $max_passes . ' passes; conditional fields failed closed.'
);
foreach ( $field_schema as $name => $config ) {
if ( ! empty( $config['conditions'] ) ) {
$visibility[ $name ] = false;
}
}
}
$hidden_fields = [];
foreach ( $visibility as $name => $is_visible ) {
if ( ! $is_visible ) {
$hidden_fields[] = $name;
}
}
return $hidden_fields;
}
/**
* Convert a schema-list entry into the map shape the sanitizer expects.
*
* Schema list uses `options` as array-of-{value,label}; the sanitizer
* wants `allowedValues` as a flat array of strings.
*
* @param array $entry Schema entry.
* @return array
*/
private static function schema_entry_to_map( $entry ) {
$map = [
'type' => $entry['type'] ?? 'text',
'required' => ! empty( $entry['required'] ),
'label' => $entry['label'] ?? $entry['name'] ?? '',
];
if ( isset( $entry['options'] ) && is_array( $entry['options'] ) ) {
$values = [];
foreach ( $entry['options'] as $option ) {
if ( isset( $option['value'] ) && '' !== $option['value'] ) {
$values[] = (string) $option['value'];
}
}
$map['allowedValues'] = $values;
}
if ( isset( $entry['checkedValue'] ) ) {
$map['checkedValue'] = $entry['checkedValue'];
}
if ( isset( $entry['conditions'] ) && is_array( $entry['conditions'] ) ) {
$map['conditions'] = $entry['conditions'];
}
if ( isset( $entry['matchesField'] ) && is_string( $entry['matchesField'] ) ) {
$map['matchesField'] = $entry['matchesField'];
}
return $map;
}
/**
* Evaluate conditions for a field against submitted data.
*
* All conditions must be met (AND logic). If any trigger field is missing
* from the schema, the condition is treated as not met.
*
* @param array $conditions Conditions.
* @param array $field_schema Full schema.
* @param array $raw_data Raw data.
* @return bool
*/
private static function evaluate_conditions( $conditions, $field_schema, $raw_data ) {
foreach ( $conditions as $condition ) {
$trigger_name = $condition['field'] ?? '';
$operator = $condition['operator'] ?? 'is';
$expected = $condition['value'] ?? '';
if ( ! $trigger_name || ! isset( $field_schema[ $trigger_name ] ) ) {
return false;
}
$actual = $raw_data[ $trigger_name ] ?? '';
$is_arr = is_array( $actual );
if ( ! $is_arr ) {
$actual = sanitize_text_field( (string) $actual );
}
switch ( $operator ) {
case 'is':
if ( $is_arr ? ! in_array( $expected, $actual, true ) : $actual !== $expected ) {
return false;
}
break;
case 'isnot':
if ( $is_arr ? in_array( $expected, $actual, true ) : $actual === $expected ) {
return false;
}
break;
case 'isempty':
if ( $is_arr ? ! empty( $actual ) : '' !== trim( $actual ) ) {
return false;
}
break;
case 'isnotempty':
if ( $is_arr ? empty( $actual ) : '' === trim( $actual ) ) {
return false;
}
break;
default:
return false;
}
}
return true;
}
}
@@ -0,0 +1,379 @@
<?php
/**
* Shared form render helper.
*
* One function renders a form for both the public runtime and the editor
* preview. Permission behavior differs by context:
*
* - public: only accepts `publish` forms.
* - editor: accepts published forms for users who can use forms, and
* accepts draft/private forms only for users who can edit the form.
*
* This keeps JS and PHP aligned — no client-side rendering of fields, no
* drift between what's previewed and what's submitted.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Form render helper.
*/
class GenerateBlocks_Pro_Form_Render {
/**
* Per-request render counter. Bumped on each render() so the same form
* embedded twice on a page produces non-colliding field IDs.
*
* @var int
*/
private static $instance_counter = 0;
/**
* Active render frame, or null when no render is in flight.
*
* Holds form id, host post id, config, context, instance number, and a
* counter of Form-block render-callback invocations during the current
* render. The Form block render callback reads this frame so runtime
* attribute injection is scoped to the actual Form block output, never to
* arbitrary <form> tags rendered by wrapper or sibling blocks. Nested
* renders are rejected outright (see render() guard) so a single frame is
* sufficient.
*
* @var array|null
*/
private static $current_render = null;
/**
* Render a form by post ID.
*
* @param int $form_id Form post ID.
* @param string $context 'public' or 'editor'.
* @param array $overrides Render-time overrides (e.g. host post ID for context).
* @return string|WP_Error Rendered HTML on success, WP_Error on failure.
*/
public static function render( $form_id, $context = 'public', $overrides = [] ) {
$form_id = absint( $form_id );
if ( ! $form_id ) {
return new WP_Error( 'invalid_form_id', 'Invalid form ID.' );
}
$form = get_post( $form_id );
if ( ! $form || GenerateBlocks_Pro_Form_Post_Type::POST_TYPE !== $form->post_type ) {
return new WP_Error( 'form_not_found', 'Form not found.' );
}
if ( null !== self::$current_render ) {
return new WP_Error( 'form_render_recursion', 'Nested form renderer detected.' );
}
// Public runtime: only render published forms. Editor preview allows
// published forms for users who can use forms, but draft/private forms
// still require edit access on the specific form post.
if ( 'public' === $context ) {
if ( 'publish' !== $form->post_status ) {
return new WP_Error( 'form_not_published', 'Form not published.' );
}
} else {
if ( 'publish' !== $form->post_status && ! current_user_can( 'edit_post', $form_id ) ) {
return new WP_Error( 'cannot_edit_form', 'Cannot preview this form.' );
}
if (
'publish' === $form->post_status
&& ! GenerateBlocks_Pro_Form_Post_Type::current_user_can( 'use' )
&& ! current_user_can( 'edit_post', $form_id )
) {
return new WP_Error( 'cannot_edit_form', 'Cannot preview this form.' );
}
}
$meta = get_post_meta( $form_id, GenerateBlocks_Pro_Form_Post_Type::META_KEY, true );
$config = is_array( $meta ) && isset( $meta['config'] ) && is_array( $meta['config'] )
? $meta['config']
: [];
$host_post_id = isset( $overrides['host_post_id'] ) ? absint( $overrides['host_post_id'] ) : 0;
// Bump the per-request instance counter and tell the form-field
// renderer to suffix its IDs with it. Reset after `do_blocks()` so
// nested rendering paths and tests aren't affected by leftover state.
self::$instance_counter++;
$instance = self::$instance_counter;
self::$current_render = [
'form_id' => $form_id,
'host_post_id' => $host_post_id,
'config' => $config,
'context' => $context,
'instance' => $instance,
'rendered_form_blocks' => 0,
];
if ( class_exists( 'GenerateBlocks_Block_Form_Field' ) ) {
GenerateBlocks_Block_Form_Field::set_render_instance( $instance );
}
try {
$fields_html = self::render_fields( $form->post_content, $context );
$rendered_form_blocks = self::$current_render['rendered_form_blocks'];
if ( 0 === $rendered_form_blocks ) {
return new WP_Error( 'form_block_missing', 'Form block missing.' );
}
if ( $rendered_form_blocks > 1 ) {
return new WP_Error( 'form_multiple_blocks', 'Multiple Form blocks found.' );
}
if ( '' === trim( $fields_html ) ) {
return new WP_Error( 'form_empty', 'Form has no fields.' );
}
return $fields_html;
} finally {
self::$current_render = null;
if ( class_exists( 'GenerateBlocks_Block_Form_Field' ) ) {
GenerateBlocks_Block_Form_Field::set_render_instance( 0 );
}
}
}
/**
* Render the inner field blocks.
*
* Uses do_blocks so the full render pipeline runs — dynamic tags,
* conditional attributes, and generated classes stay consistent with how
* inner form-field blocks render anywhere else.
*
* Editor preview runs inside a REST request — wp_head never fires, so
* GenerateBlocks' default "queue CSS for wp_head" path drops the block
* CSS on the floor. Force inline <style> output for editor context so
* the preview matches the public render visually.
*
* @param string $content Form post_content.
* @param string $context 'public' or 'editor'.
* @return string
*/
private static function render_fields( $content, $context = 'public' ) {
if ( ! is_string( $content ) || '' === trim( $content ) ) {
return '';
}
if ( 'editor' === $context ) {
add_filter( 'generateblocks_do_inline_styles', '__return_true' );
try {
return do_blocks( $content );
} finally {
remove_filter( 'generateblocks_do_inline_styles', '__return_true' );
}
}
return do_blocks( $content );
}
/**
* Add runtime submission attributes to the rendered Form block.
*
* Called by GenerateBlocks_Block_Form::render_block(). This scopes form
* mutation to the actual Form block output instead of scanning arbitrary
* forms that may appear in wrapper/sibling blocks.
*
* @param string $block_content Rendered Form block HTML.
* @return string
*/
public static function prepare_form_block_output( $block_content ) {
if ( null === self::$current_render ) {
return $block_content;
}
self::$current_render['rendered_form_blocks']++;
return self::build_wrapper(
(int) self::$current_render['form_id'],
(int) self::$current_render['host_post_id'],
(array) self::$current_render['config'],
$block_content,
(string) self::$current_render['context'],
(int) self::$current_render['instance']
);
}
/**
* Augment the Form block's rendered <form> tag with submission attrs,
* and append integration / message / noscript HTML before </form>.
*
* The Form block (canvas) already emits `<form class="gb-form">...</form>`
* via its save/render; this helper uses WP_HTML_Tag_Processor to set
* method/novalidate/data-* on that existing tag without re-wrapping.
* Editor previews then downgrade that outer tag to a `<div>` so Gutenberg
* never hosts a live nested form.
*
* @param int $form_id Form post ID.
* @param int $host_post_id Host page ID (for HMAC scope + page_url in actions).
* @param array $config Form config from meta.
* @param string $form_html Rendered form block HTML (includes the <form> tag).
* @param string $context 'public' or 'editor'.
* @param int $instance Per-render instance number (matches the field-id suffix).
* @return string
*/
private static function build_wrapper( $form_id, $host_post_id, $config, $form_html, $context, $instance = 1 ) {
$defaults = GenerateBlocks_Pro_Form_Post_Type::default_form_config();
$success_message = ! empty( $config['successMessage'] ) ? $config['successMessage'] : $defaults['successMessage'];
$error_message = ! empty( $config['errorMessage'] ) ? $config['errorMessage'] : $defaults['errorMessage'];
$redirect_url = $config['redirectUrl'] ?? '';
$attrs = [
'data-gb-form-id' => (string) $form_id,
// Read by form.js to suffix the honeypot input ID so the same form
// embedded twice doesn't produce duplicate hidden honeypot input IDs.
'data-gb-instance' => (string) $instance,
];
if ( 'editor' === $context ) {
$attrs['role'] = 'form';
// `host` distinguishes "previewed inside another post via the
// Form Render block" from `canvas` (the form CPT editor where
// authors actually design the form). Authors can't edit the
// form from a host page, so we keep the public-style hidden
// state of the success container — the form stays compact and
// looks like the live frontend.
$attrs['data-gb-preview'] = 'host';
} else {
$attrs['method'] = 'post';
$attrs['novalidate'] = 'novalidate';
$attrs['data-gb-post-id'] = (string) $host_post_id;
$attrs['data-gb-form-endpoint'] = esc_url_raw( rest_url( 'generateblocks-pro/v1/forms/submit' ) );
$attrs['data-gb-security-endpoint'] = esc_url_raw( rest_url( 'generateblocks-pro/v1/forms/security' ) );
$attrs['data-gb-success-message'] = $success_message;
$attrs['data-gb-error-message'] = $error_message;
if ( '' !== $redirect_url ) {
$attrs['data-gb-redirect-url'] = esc_url_raw( $redirect_url );
}
}
$processor = new WP_HTML_Tag_Processor( $form_html );
if ( $processor->next_tag( [ 'tag_name' => 'form' ] ) ) {
foreach ( $attrs as $key => $value ) {
$processor->set_attribute( $key, (string) $value );
}
$form_html = $processor->get_updated_html();
}
/**
* Filter HTML injected just before the Form block's closing </form>.
*
* Internal hook used by the bundled Turnstile integration to add its
* widget markup. Not yet documented as a public extension point —
* the JS-side provider contract is still in flux. Callbacks must
* escape their own output.
*
* @param string $html Accumulated HTML. Default empty string.
* @param int $form_id Form post ID.
* @param int $host_post_id Host page ID (0 in editor preview).
* @param array $config Form config from meta.
* @param string $context 'public' or 'editor'.
* @param int $instance Per-render instance number.
*/
$integration_html = apply_filters(
'generateblocks_form_render_footer_html',
'',
$form_id,
$host_post_id,
$config,
$context,
$instance
);
$integration_html = is_string( $integration_html ) ? $integration_html : '';
// role="status" + aria-live so a server-rendered or no-JS-fallback error
// in this container is announced even before form.js can upgrade the role.
// showMessage() flips role to "alert" + aria-live to "assertive" for errors.
$message_el = '<div class="gb-form-message" role="status" aria-live="polite" aria-atomic="true" hidden></div>';
$noscript = 'public' === $context
? '<noscript><p>' . esc_html__( 'Please enable JavaScript to submit this form.', 'generateblocks-pro' ) . '</p></noscript>'
: '';
$injected = $integration_html . $message_el . $noscript;
if ( '' !== $injected ) {
// Inject before the last </form>. strripos beats `</form>\s*$`
// because third-party render_block filters can append comments
// or whitespace past the close tag, defeating the end anchor.
$close_pos = strripos( $form_html, '</form>' );
if ( false !== $close_pos ) {
$form_html = substr_replace( $form_html, $injected, $close_pos, 0 );
}
}
if ( 'editor' === $context ) {
$form_html = self::downgrade_editor_preview_form_tag( $form_html );
}
return $form_html;
}
/**
* Replace the outer preview `<form>` wrapper with `<div>`.
*
* Nested live forms inside Gutenberg can submit the admin post editor.
* Editor previews are visual only, so keep the same attributes/classes
* but switch the tag to a neutral container.
*
* @param string $html Form HTML.
* @return string
*/
private static function downgrade_editor_preview_form_tag( $html ) {
if ( ! is_string( $html ) || '' === trim( $html ) ) {
return '';
}
$html = preg_replace( '/<form\b/i', '<div', $html, 1 );
$close_pos = strripos( $html, '</form>' );
if ( false === $close_pos ) {
return $html;
}
return substr_replace( $html, '</div>', $close_pos, strlen( '</form>' ) );
}
/**
* Enqueue the public runtime assets.
*
* Called from the Form block render path when a publish-status form
* is rendered on a public page.
*/
public static function enqueue_public_assets() {
if ( ! wp_style_is( 'generateblocks-form', 'enqueued' ) ) {
wp_enqueue_style(
'generateblocks-form',
GENERATEBLOCKS_PRO_DIR_URL . 'dist/form-style.css',
[],
GENERATEBLOCKS_PRO_VERSION
);
}
if ( ! wp_script_is( 'generateblocks-form', 'enqueued' ) ) {
wp_enqueue_script(
'generateblocks-form',
GENERATEBLOCKS_PRO_DIR_URL . 'dist/form.js',
[],
GENERATEBLOCKS_PRO_VERSION,
true
);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,423 @@
<?php
/**
* Form field sanitization.
*
* Every form value passes through this class. No exceptions.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Form sanitizer class.
*/
class GenerateBlocks_Pro_Form_Sanitizer {
/**
* Allowed field types and their sanitization callbacks.
*
* @var array
*/
private static $type_sanitizers = [
'text' => 'sanitize_text_field',
'textarea' => 'sanitize_textarea_field',
'email' => 'sanitize_email',
'url' => 'esc_url_raw',
'tel' => [ __CLASS__, 'sanitize_tel' ],
'number' => [ __CLASS__, 'sanitize_number' ],
'hidden' => 'sanitize_text_field',
'select' => 'sanitize_text_field',
'checkbox' => 'sanitize_text_field',
'radio' => 'sanitize_text_field',
'checkbox-group' => 'sanitize_text_field',
];
/**
* Maximum allowed length for any single field value.
*
* @var int
*/
const MAX_FIELD_LENGTH = 10000;
/**
* Sanitize a single field value based on its registered type.
*
* @param string $value The raw field value.
* @param string $field_type The field type (text, email, url, tel, number, hidden).
* @return string Sanitized value.
*/
public static function sanitize_field( $value, $field_type ) {
// Force string.
$value = (string) $value;
// Strip null bytes.
$value = str_replace( "\0", '', $value );
// Enforce max length. mbstring is near-universal on modern PHP but
// we fall back to byte substr so a missing extension doesn't fatal.
$value = function_exists( 'mb_substr' )
? mb_substr( $value, 0, self::MAX_FIELD_LENGTH )
: substr( $value, 0, self::MAX_FIELD_LENGTH );
/**
* Filter the sanitizers for form field types.
*
* Allows third-party developers to register custom field type sanitizers.
*
* @since 2.6.0
* @param array $type_sanitizers Field type => sanitizer callback map.
*/
$type_sanitizers = apply_filters( 'generateblocks_form_type_sanitizers', self::$type_sanitizers );
// Get the sanitizer for this type, default to sanitize_text_field.
$sanitizer = $type_sanitizers[ $field_type ] ?? 'sanitize_text_field';
if ( is_callable( $sanitizer ) ) {
return call_user_func( $sanitizer, $value );
}
return sanitize_text_field( $value );
}
/**
* Sanitize all fields from a submission against the registered schema.
*
* Iterates over the SCHEMA (server-derived), not the user input.
* Unknown fields are silently dropped.
*
* @param array $raw_data The raw submitted data (field_name => value).
* @param array $field_schema The registered field schema (field_name => ['type' => ..., 'required' => ...]).
* @return array|WP_Error Sanitized data array or WP_Error on validation failure.
*/
public static function sanitize_submission( $raw_data, $field_schema ) {
$sanitized = [];
$errors = [];
foreach ( $field_schema as $field_name => $field_config ) {
$type = $field_config['type'] ?? 'text';
$required = $field_config['required'] ?? false;
$label = $field_config['label'] ?? $field_name;
$value = $raw_data[ $field_name ] ?? '';
// Check required fields.
$is_empty = is_array( $value ) ? empty( $value ) : '' === trim( (string) $value );
if ( $required && $is_empty ) {
$errors[] = $field_name;
continue;
}
// Checkbox-group: value arrives as an array from FormData's getAll().
// Sanitize each item individually, validate against allowed options, then join.
if ( 'checkbox-group' === $type ) {
$raw_values = is_array( $value ) ? $value : ( '' !== $value ? [ $value ] : [] );
$allowed_values = $field_config['allowedValues'] ?? [];
$clean_parts = [];
foreach ( $raw_values as $raw_item ) {
$clean_item = self::sanitize_field( (string) $raw_item, $type );
if ( '' === $clean_item ) {
continue;
}
if ( ! empty( $allowed_values ) && ! in_array( $clean_item, $allowed_values, true ) ) {
continue; // Skip invalid options silently.
}
$clean_parts[] = $clean_item;
}
$clean_value = implode( ', ', $clean_parts );
if ( $required && '' === $clean_value ) {
$errors[] = $field_name;
continue;
}
$sanitized[ $field_name ] = [
'value' => $clean_value,
'label' => sanitize_text_field( $label ),
'type' => $type,
];
continue;
}
// Reject array-shaped values for scalar field types. Only
// checkbox-group expects an array (handled above); for every
// other type a non-scalar arrives only via tampered submissions.
// Casting an array to string would raise a PHP warning and
// persist the literal "Array" — fail cleanly instead.
if ( ! is_scalar( $value ) && null !== $value ) {
if ( $required ) {
$errors[] = $field_name;
continue;
}
$value = '';
}
// Sanitize the value.
$clean_value = self::sanitize_field( $value, $type );
// Required + invalid typed value: sanitize_email/sanitize_number/
// esc_url_raw return '' for malformed input. The pre-sanitize
// emptiness check above only catches values that were empty as
// submitted, so a required number field with "abc" or a required
// email with "notanemail" would otherwise persist as an empty
// string. Treat post-sanitize empty as the same failure mode.
if ( $required && '' === trim( (string) $clean_value ) ) {
$errors[] = $field_name;
continue;
}
// Validate select and radio values against allowed options.
if ( ( 'select' === $type || 'radio' === $type ) && '' !== $clean_value ) {
$allowed_values = $field_config['allowedValues'] ?? [];
if ( ! in_array( $clean_value, $allowed_values, true ) ) {
$errors[] = $field_name;
continue;
}
}
// Validate checkbox values.
if ( 'checkbox' === $type && '' !== $clean_value ) {
$checked_value = $field_config['checkedValue'] ?? 'yes';
if ( $clean_value !== $checked_value ) {
// Tampered value — reject if required, otherwise blank it.
if ( $required ) {
$errors[] = $field_name;
continue;
}
$clean_value = '';
}
}
$sanitized[ $field_name ] = [
'value' => $clean_value,
'label' => sanitize_text_field( $label ),
'type' => $type,
];
}
if ( ! empty( $errors ) ) {
return new WP_Error(
'missing_required_fields',
__( 'Required fields are missing.', 'generateblocks-pro' ),
[ 'fields' => $errors ]
);
}
$mismatched = self::validate_matching_fields( $sanitized, $field_schema );
if ( ! empty( $mismatched['fields'] ) ) {
return new WP_Error(
'mismatched_fields',
__( 'Fields do not match.', 'generateblocks-pro' ),
[
'fields' => $mismatched['fields'],
'public_message' => $mismatched['public_message'],
]
);
}
return $sanitized;
}
/**
* Validate same-value field match rules after sanitization.
*
* @param array $sanitized Sanitized field data.
* @param array $field_schema Schema map.
* @return array Mismatched field names and public message.
*/
private static function validate_matching_fields( $sanitized, $field_schema ) {
$errors = [];
$public_message = '';
foreach ( $field_schema as $field_name => $field_config ) {
$matches_field = sanitize_key( $field_config['matchesField'] ?? '' );
if (
'' === $matches_field ||
$field_name === $matches_field ||
! isset( $field_schema[ $matches_field ] ) ||
! isset( $sanitized[ $field_name ], $sanitized[ $matches_field ] )
) {
continue;
}
$value = (string) ( $sanitized[ $field_name ]['value'] ?? '' );
$target_value = (string) ( $sanitized[ $matches_field ]['value'] ?? '' );
if ( $value !== $target_value ) {
$errors[] = $field_name;
if ( '' === $public_message ) {
$public_message = self::get_match_public_message(
$field_name,
$matches_field,
$sanitized,
$field_schema
);
}
}
}
return [
'fields' => $errors,
'public_message' => '' !== $public_message
? $public_message
: __( 'The matching fields must have the same value.', 'generateblocks-pro' ),
];
}
/**
* Build the public message for a failed match rule.
*
* @param string $field_name Source field name.
* @param string $matches_field Target field name.
* @param array $sanitized Sanitized field data.
* @param array $field_schema Schema map.
* @return string Public-facing message.
*/
private static function get_match_public_message( $field_name, $matches_field, $sanitized, $field_schema ) {
$field_label = self::get_field_label( $field_name, $sanitized, $field_schema );
$target_label = self::get_field_label( $matches_field, $sanitized, $field_schema );
if ( '' !== $field_label && '' !== $target_label ) {
return sprintf(
/* translators: %1$s: source field label, %2$s: target field label. */
__( 'Please make sure "%1$s" matches "%2$s".', 'generateblocks-pro' ),
$field_label,
$target_label
);
}
if ( '' !== $target_label ) {
return sprintf(
/* translators: %s: target field label. */
__( 'This field must match "%s".', 'generateblocks-pro' ),
$target_label
);
}
return __( 'The matching fields must have the same value.', 'generateblocks-pro' );
}
/**
* Get a sanitized display label for a schema field.
*
* @param string $field_name Field name.
* @param array $sanitized Sanitized field data.
* @param array $field_schema Schema map.
* @return string Field label, or an empty string when unavailable.
*/
private static function get_field_label( $field_name, $sanitized, $field_schema ) {
$label = $sanitized[ $field_name ]['label'] ?? $field_schema[ $field_name ]['label'] ?? '';
$label = trim( sanitize_text_field( (string) $label ) );
return $label;
}
/**
* Sanitize a value for use in email headers.
*
* Strips newlines and carriage returns to prevent header injection.
*
* @param string $value The raw value.
* @return string Sanitized value safe for email headers.
*/
public static function sanitize_email_header( $value ) {
// Strip all variations of newlines that could be used for header injection.
$value = preg_replace( '/[\r\n\t]/', '', $value );
$value = str_replace( [ '%0a', '%0d', '%0A', '%0D' ], '', $value );
return sanitize_text_field( $value );
}
/**
* Replace {field:name} merge tags with sanitized field values.
*
* Uses sanitize_textarea_field() for textarea fields to preserve newlines,
* and sanitize_text_field() for everything else.
*
* @param mixed $text The text containing merge tags.
* @param array $sanitized_data The sanitized form data.
* @return string Text with merge tags replaced.
*/
public static function replace_merge_tags( $text, $sanitized_data ) {
$text = is_scalar( $text ) ? (string) $text : '';
if ( ! is_array( $sanitized_data ) || false === strpos( $text, '{' ) ) {
return $text;
}
$replaced = preg_replace_callback(
'/\{field:([a-z0-9_-]+)\}/i',
function ( $matches ) use ( $sanitized_data ) {
$field_name = sanitize_key( $matches[1] );
$field = $sanitized_data[ $field_name ] ?? null;
if ( is_array( $field ) ) {
$value = (string) ( $field['value'] ?? '' );
$type = $field['type'] ?? 'text';
return 'textarea' === $type
? sanitize_textarea_field( $value )
: sanitize_text_field( $value );
}
// Unknown tag — leave as-is for debugging.
return $matches[0];
},
$text
);
return is_string( $replaced ) ? $replaced : $text;
}
/**
* Sanitize a telephone number.
*
* @param string $value The raw value.
* @return string Sanitized telephone number.
*/
public static function sanitize_tel( $value ) {
return preg_replace( '/[^\d\s\+\-\(\)\.]/', '', $value );
}
/**
* Sanitize a number value.
*
* Rejects non-numeric input (returns '') instead of coercing to 0 —
* storing "0" for "abc" silently corrupts downstream data.
*
* @param string $value The raw value.
* @return string Sanitized number as string, or '' if not numeric.
*/
public static function sanitize_number( $value ) {
if ( '' === $value ) {
return '';
}
if ( ! is_numeric( $value ) ) {
return '';
}
$num = floatval( $value );
if ( is_infinite( $num ) || is_nan( $num ) ) {
return '';
}
return (string) $num;
}
}
@@ -0,0 +1,846 @@
<?php
/**
* Form schema builder.
*
* Parses and validates a `gblocks_form` post's field structure from its
* saved block content. Submission processing derives field definitions from
* post_content at request time; save hooks use this class only to surface
* editor notices for invalid field setups.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Form schema builder class.
*/
class GenerateBlocks_Pro_Form_Schema_Builder extends GenerateBlocks_Pro_Singleton {
const NOTICE_TRANSIENT_PREFIX = 'gb_form_schema_error_';
/**
* Allowed field types.
*
* Kept in one place — the sanitizer's type map should match.
*
* @var array
*/
private static $allowed_types = [
'text',
'email',
'url',
'tel',
'number',
'hidden',
'textarea',
'select',
'checkbox',
'radio',
'checkbox-group',
];
/**
* Allowed condition operators (post-sanitize_key — all lowercase).
*
* @var array
*/
private static $allowed_operators = [ 'is', 'isnot', 'isempty', 'isnotempty' ];
/**
* Option field types whose allowed-value list must be non-empty.
*
* An empty allowlist would let any submitted value slip through the
* processor's in_array() validation, so we reject the save entirely
* rather than persist a config that can't enforce itself.
*
* @var array
*/
private static $option_types = [ 'select', 'radio', 'checkbox-group' ];
/**
* Field types that support same-value match validation.
*
* @var array
*/
private static $matchable_types = [ 'text', 'email', 'url', 'tel', 'number', 'textarea' ];
/**
* Field names reserved for internal config sentinels.
*
* @var array
*/
private static $reserved_field_names = [ '__none' ];
/**
* Initialize.
*/
public function init() {
// Block REST writes outright when the schema is invalid — a saved
// form with duplicate field names or invalid options would publish
// fine but reject every submission with a generic error, which is
// worse UX than failing the save in the editor.
add_filter( 'rest_pre_insert_' . GenerateBlocks_Pro_Form_Post_Type::POST_TYPE, [ $this, 'gate_rest_save' ], 10, 2 );
// Non-REST save fallback (classic editor, programmatic inserts):
// keep the post-save transient-notice path so the user at least sees
// what's wrong on the next admin page load.
add_action( 'save_post_' . GenerateBlocks_Pro_Form_Post_Type::POST_TYPE, [ $this, 'validate_schema' ], 10, 3 );
add_action( 'admin_notices', [ $this, 'render_admin_notices' ] );
}
/**
* Reject REST writes whose post_content fails schema validation.
*
* Runs before the post hits the database, so the editor's save flow
* surfaces the WP_Error inline instead of letting an invalid form
* publish and silently break submissions.
*
* @param stdClass $prepared_post Prepared post object as a stdClass.
* @param WP_REST_Request $request The REST request.
* @return stdClass|WP_Error The prepared post, or WP_Error to block the save.
*/
public function gate_rest_save( $prepared_post, $request ) {
unset( $request );
if ( ! is_object( $prepared_post ) || ! isset( $prepared_post->post_content ) ) {
return $prepared_post;
}
$schema = $this->build_schema_from_content( (string) $prepared_post->post_content );
if ( is_wp_error( $schema ) ) {
return new WP_Error(
$schema->get_error_code(),
$schema->get_error_message(),
[ 'status' => 400 ]
);
}
return $prepared_post;
}
/**
* Validate the form's saved field structure after a form post saves.
*
* Skip revisions, autosaves, and auto-drafts — they don't carry the live
* content and running on them would thrash notice state with partial data.
*
* @param int $post_id Post ID.
* @param WP_Post $post Post object.
* @param bool $update Whether this is an update.
*/
public function validate_schema( $post_id, $post, $update ) {
unset( $update );
if (
wp_is_post_revision( $post_id )
|| wp_is_post_autosave( $post_id )
|| 'auto-draft' === $post->post_status
) {
return;
}
$schema = $this->build_schema_from_content( $post->post_content );
if ( is_wp_error( $schema ) ) {
$this->record_error( $post_id, $schema );
return;
}
delete_transient( self::NOTICE_TRANSIENT_PREFIX . $post_id );
}
/**
* Parse post_content, walk form-field blocks, build sanitized schema.
*
* @param string $content Post content.
* @param array $args Optional validation args.
* @return array|WP_Error Schema array on success, WP_Error on validation failure.
*/
public function build_schema_from_content( $content, $args = [] ) {
$args = wp_parse_args(
$args,
[
'require_field_names' => false,
]
);
if ( ! is_string( $content ) || '' === trim( $content ) ) {
return new WP_Error(
'gb_form_missing_form_block',
__( 'This form is missing a Form block. Add one Form block before saving.', 'generateblocks-pro' )
);
}
$blocks = parse_blocks( $content );
$fields = [];
$disallowed_block = $this->find_disallowed_form_block( $blocks );
if ( is_wp_error( $disallowed_block ) ) {
return $disallowed_block;
}
$form_blocks = $this->find_form_blocks( $blocks );
if ( empty( $form_blocks ) ) {
return new WP_Error(
'gb_form_missing_form_block',
__( 'This form is missing a Form block. Add one Form block before saving.', 'generateblocks-pro' )
);
}
if ( count( $form_blocks ) > 1 ) {
return new WP_Error(
'gb_form_multiple_form_blocks',
__( 'This form has multiple Form blocks. Keep only one Form block before saving.', 'generateblocks-pro' )
);
}
$form_inner_blocks = isset( $form_blocks[0]['innerBlocks'] ) && is_array( $form_blocks[0]['innerBlocks'] )
? $form_blocks[0]['innerBlocks']
: [];
$collected = $this->collect_fields( $form_inner_blocks, $fields );
if ( is_wp_error( $collected ) ) {
return $collected;
}
$schema = [];
$seen_keys = [];
foreach ( $fields as $attrs ) {
$entry = $this->build_field_entry( $attrs, $args );
if ( is_wp_error( $entry ) ) {
return $entry;
}
if ( null === $entry ) {
continue;
}
if ( isset( $seen_keys[ $entry['name'] ] ) ) {
return new WP_Error(
'gb_form_duplicate_field_name',
sprintf(
/* translators: %s: duplicate field name */
__( 'Duplicate field name: %s. Field names must be unique within a form.', 'generateblocks-pro' ),
$entry['name']
)
);
}
$seen_keys[ $entry['name'] ] = true;
$schema[] = $entry;
}
return $this->sanitize_match_references( $schema );
}
/**
* Recursively find static Form blocks in parsed post content.
*
* Reusable blocks are intentionally not followed here. The Form block must
* live in the saved Form CPT content so render output and submission schema
* are derived from the same document.
*
* @param array $blocks Parsed blocks.
* @return array<int,array> Matching Form blocks.
*/
private function find_form_blocks( $blocks ) {
$matches = [];
foreach ( $blocks as $block ) {
$name = $block['blockName'] ?? '';
if ( 'generateblocks-pro/form' === $name ) {
$matches[] = $block;
}
if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
$matches = array_merge( $matches, $this->find_form_blocks( $block['innerBlocks'] ) );
}
}
return $matches;
}
/**
* Recursively collect form-field definitions.
*
* @param array $blocks Parsed blocks.
* @param array $out Accumulator.
* @return WP_Error|null
*/
private function collect_fields( $blocks, array &$out ) {
foreach ( $blocks as $block ) {
$name = $block['blockName'] ?? '';
if ( 'generateblocks-pro/form-field' === $name ) {
$attrs = $this->build_attrs_from_field_block( $block );
if ( is_wp_error( $attrs ) ) {
return $attrs;
}
$out[] = $attrs;
continue;
}
if ( ! empty( $block['innerBlocks'] ) ) {
$result = $this->collect_fields( $block['innerBlocks'], $out );
if ( is_wp_error( $result ) ) {
return $result;
}
}
}
return null;
}
/**
* Build the schema-facing field attrs from a wrapper + child blocks.
*
* The wrapper owns field identity/behavior; label/control children own
* their own content/settings. The schema flattens those pieces because
* the submission processor works from one field definition per field.
*
* @param array $block Parsed form-field wrapper block.
* @return array|WP_Error Flattened field attributes or an error for ambiguous markup.
*/
private function build_attrs_from_field_block( $block ) {
$attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
$field_name = sanitize_key( $attrs['fieldName'] ?? '' );
$label_blocks = $this->find_inner_block_attrs( $block, 'generateblocks-pro/form-field-label' );
$control_blocks = $this->find_inner_block_attrs( $block, 'generateblocks-pro/form-field-control' );
if ( count( $label_blocks ) > 1 ) {
return new WP_Error(
'gb_form_multiple_labels',
sprintf(
/* translators: %s: field name */
__( 'Field "%s" has multiple labels. Each field can only use one Form Field Label block.', 'generateblocks-pro' ),
$field_name ? $field_name : __( '(unnamed)', 'generateblocks-pro' )
)
);
}
if ( count( $control_blocks ) > 1 ) {
return new WP_Error(
'gb_form_multiple_controls',
sprintf(
/* translators: %s: field name */
__( 'Field "%s" has multiple controls. Each field can only use one Form Field Control block.', 'generateblocks-pro' ),
$field_name ? $field_name : __( '(unnamed)', 'generateblocks-pro' )
)
);
}
if ( $field_name && empty( $control_blocks ) ) {
return new WP_Error(
'gb_form_missing_control',
sprintf(
/* translators: %s: field name */
__( 'Field "%s" is missing a Form Field Control block.', 'generateblocks-pro' ),
$field_name
)
);
}
$label_attrs = $label_blocks[0] ?? [];
$control_attrs = $control_blocks[0] ?? [];
$field_type = $attrs['fieldType'] ?? '';
if ( '' !== $field_type && in_array( $field_type, [ 'radio', 'checkbox-group' ], true ) && ! empty( $label_blocks ) && ! $this->has_first_direct_inner_block( $block, 'generateblocks-pro/form-field-label' ) ) {
return new WP_Error(
'gb_form_group_label_position',
sprintf(
/* translators: %s: field name */
__( 'Field "%s" uses a group label, so its Form Field Label block must be the first block directly inside the Form Field block.', 'generateblocks-pro' ),
$field_name ? $field_name : __( '(unnamed)', 'generateblocks-pro' )
)
);
}
if ( ! empty( $label_blocks ) && ! $this->has_direct_inner_block( $block, 'generateblocks-pro/form-field-label' ) ) {
return new WP_Error(
'gb_form_nested_label',
sprintf(
/* translators: %s: field name */
__( 'Field "%s" has a nested Form Field Label block. Add the label directly inside the Form Field block.', 'generateblocks-pro' ),
$field_name ? $field_name : __( '(unnamed)', 'generateblocks-pro' )
)
);
}
if ( isset( $label_attrs['content'] ) ) {
$attrs['label'] = $label_attrs['content'];
}
foreach ( [ 'placeholder', 'rows', 'options', 'checkedValue', 'defaultValue' ] as $key ) {
if ( array_key_exists( $key, $control_attrs ) ) {
$attrs[ $key ] = $control_attrs[ $key ];
}
}
return $attrs;
}
/**
* Find matching child block attrs inside a parsed form-field block.
*
* Layout blocks are allowed inside a field, so controls may be nested. Label
* lookup remains recursive for defensive parsing; group labels are validated
* separately because <legend> must be the first fieldset child.
*
* @param array $block Parsed block.
* @param string $block_name Block name.
* @return array<int,array>
*/
private function find_inner_block_attrs( $block, $block_name ) {
$matches = [];
if ( empty( $block['innerBlocks'] ) || ! is_array( $block['innerBlocks'] ) ) {
return $matches;
}
foreach ( $block['innerBlocks'] as $inner_block ) {
$inner_name = $inner_block['blockName'] ?? '';
if ( 'generateblocks-pro/form-field' === $inner_name ) {
continue;
}
if ( $block_name === $inner_name ) {
$matches[] = isset( $inner_block['attrs'] ) && is_array( $inner_block['attrs'] ) ? $inner_block['attrs'] : [];
continue;
}
$matches = array_merge( $matches, $this->find_inner_block_attrs( $inner_block, $block_name ) );
}
return $matches;
}
/**
* Check whether the first direct inner block matches a block name.
*
* Group fields render labels as <legend>, which is only valid when it is the
* first child of the fieldset. Controls may still be nested in layout blocks.
*
* @param array $block Parsed block.
* @param string $block_name Block name.
* @return bool
*/
private function has_first_direct_inner_block( $block, $block_name ) {
if ( empty( $block['innerBlocks'] ) || ! is_array( $block['innerBlocks'] ) ) {
return false;
}
$first_block = $block['innerBlocks'][0] ?? [];
return ( $first_block['blockName'] ?? '' ) === $block_name;
}
/**
* Check whether a direct inner block matches a block name.
*
* @param array $block Parsed block.
* @param string $block_name Block name.
* @return bool
*/
private function has_direct_inner_block( $block, $block_name ) {
if ( empty( $block['innerBlocks'] ) || ! is_array( $block['innerBlocks'] ) ) {
return false;
}
foreach ( $block['innerBlocks'] as $inner_block ) {
if ( ( $inner_block['blockName'] ?? '' ) === $block_name ) {
return true;
}
}
return false;
}
/**
* Check whether parsed content contains disallowed form blocks.
*
* Synced patterns/reusable blocks are rejected outright. Forms need one
* static saved document so rendering, submission schema, and validation all
* read from the same source of truth.
*
* @param array $blocks Parsed blocks.
* @param bool $inside_form_block Whether the current block list is inside a Form block.
* @param bool $inside_form_field Whether the current block list is inside a form-field block.
* @return WP_Error|null Error when a disallowed block is found.
*/
private function find_disallowed_form_block( $blocks, $inside_form_block = false, $inside_form_field = false ) {
foreach ( $blocks as $block ) {
$name = $block['blockName'] ?? '';
$is_form_block = 'generateblocks-pro/form' === $name;
$is_form_field = 'generateblocks-pro/form-field' === $name;
$is_field_child = in_array(
$name,
[
'generateblocks-pro/form-field-label',
'generateblocks-pro/form-field-control',
],
true
);
if ( 'core/block' === $name ) {
return new WP_Error(
'gb_form_reusable_block',
__( 'Synced patterns and reusable blocks cannot be used inside forms. Add static blocks directly inside the Form post.', 'generateblocks-pro' )
);
}
if ( 'generateblocks-pro/form-render' === $name ) {
return new WP_Error(
'gb_form_disallowed_block',
__( 'Form renderer blocks cannot be used inside forms.', 'generateblocks-pro' )
);
}
if ( ! $inside_form_block && ( $is_form_field || $is_field_child ) ) {
return new WP_Error(
'gb_form_field_outside_form',
__( 'Form fields must be inside the Form block.', 'generateblocks-pro' )
);
}
if ( $inside_form_block && ! $inside_form_field && $is_field_child ) {
return new WP_Error(
'gb_form_field_part_outside_field',
__( 'Form field labels and controls must be inside a Form Field block.', 'generateblocks-pro' )
);
}
if ( $inside_form_field && $is_form_field ) {
return new WP_Error(
'gb_form_nested_field',
__( 'Form fields cannot be nested inside other form fields.', 'generateblocks-pro' )
);
}
if ( ! empty( $block['innerBlocks'] ) ) {
$disallowed_block = $this->find_disallowed_form_block(
$block['innerBlocks'],
$inside_form_block || $is_form_block,
$inside_form_field || $is_form_field
);
if ( is_wp_error( $disallowed_block ) ) {
return $disallowed_block;
}
}
}
return null;
}
/**
* Build a single sanitized field entry, or return WP_Error on invalid data.
*
* Returns null when the field should be silently skipped in permissive mode
* (e.g. empty fieldName from an unconfigured draft block).
*
* @param array $attrs Raw field attributes.
* @param array $args Validation args.
* @return array|WP_Error|null
*/
private function build_field_entry( $attrs, $args = [] ) {
$name = sanitize_key( $attrs['fieldName'] ?? '' );
if ( '' === $name ) {
if ( ! empty( $args['require_field_names'] ) ) {
return new WP_Error(
'gb_form_missing_field_name',
__( 'Every form field needs a field name before publishing.', 'generateblocks-pro' )
);
}
// Unconfigured draft field — skip without error.
return null;
}
if ( in_array( $name, self::$reserved_field_names, true ) ) {
return new WP_Error(
'gb_form_reserved_field_name',
sprintf(
/* translators: %s: reserved field name */
__( 'Field name "%s" is reserved. Choose a different field name.', 'generateblocks-pro' ),
$name
)
);
}
$type = $attrs['fieldType'] ?? 'text';
// Render fallback treats empty fieldType as text (block.json default is "").
// Normalize here so an explicit "" doesn't paint a working text input but
// fail submission with gb_form_invalid_field_type.
if ( '' === $type ) {
$type = 'text';
}
if ( ! in_array( $type, self::$allowed_types, true ) ) {
return new WP_Error(
'gb_form_invalid_field_type',
sprintf(
/* translators: %1$s: field name; %2$s: invalid field type */
__( 'Field "%1$s" has an invalid type (%2$s).', 'generateblocks-pro' ),
$name,
sanitize_text_field( (string) $type )
)
);
}
$entry = [
'name' => $name,
'type' => $type,
'required' => 'hidden' !== $type && ! empty( $attrs['isRequired'] ),
'label' => sanitize_text_field( (string) ( $attrs['label'] ?? $name ) ),
];
if ( in_array( $type, self::$option_types, true ) ) {
$options = $this->sanitize_options( $attrs['options'] ?? [], $name );
if ( is_wp_error( $options ) ) {
return $options;
}
$entry['options'] = $options;
}
if ( 'checkbox' === $type ) {
$entry['checkedValue'] = ! empty( $attrs['checkedValue'] )
? sanitize_text_field( (string) $attrs['checkedValue'] )
: 'yes';
}
if ( in_array( $type, self::$matchable_types, true ) ) {
$matches_field = sanitize_key( $attrs['matchesField'] ?? '' );
if ( '' !== $matches_field && $matches_field !== $name ) {
$entry['matchesField'] = $matches_field;
}
}
if ( ! empty( $attrs['conditions'] ) && is_array( $attrs['conditions'] ) ) {
$entry['conditions'] = $this->sanitize_conditions( $attrs['conditions'] );
}
return $entry;
}
/**
* Drop incomplete or incompatible match-validation references.
*
* @param array $schema Schema entries.
* @return array Schema entries with valid matchesField values only.
*/
private function sanitize_match_references( array $schema ) {
$field_types = [];
foreach ( $schema as $entry ) {
if ( ! empty( $entry['name'] ) && ! empty( $entry['type'] ) ) {
$field_types[ $entry['name'] ] = $entry['type'];
}
}
foreach ( $schema as $index => $entry ) {
if ( empty( $entry['matchesField'] ) ) {
continue;
}
$matches_field = sanitize_key( $entry['matchesField'] );
if (
empty( $entry['name'] ) ||
$matches_field === $entry['name'] ||
! isset( $field_types[ $matches_field ] ) ||
! in_array( $entry['type'], self::$matchable_types, true ) ||
! in_array( $field_types[ $matches_field ], self::$matchable_types, true )
) {
unset( $schema[ $index ]['matchesField'] );
continue;
}
$schema[ $index ]['matchesField'] = $matches_field;
}
return $schema;
}
/**
* Sanitize + validate the options array for a select/radio/checkbox-group.
*
* Requires at least one non-empty value. Empty allowlist is rejected
* because the processor uses in_array() to validate submitted values —
* an empty allowlist would accept anything.
*
* @param mixed $raw_options Raw options.
* @param string $field_name Field name for error messages.
* @return array|WP_Error
*/
private function sanitize_options( $raw_options, $field_name ) {
if ( ! is_array( $raw_options ) ) {
return new WP_Error(
'gb_form_missing_options',
sprintf(
/* translators: %s: field name */
__( 'Field "%s" requires options but none were provided.', 'generateblocks-pro' ),
$field_name
)
);
}
$clean = [];
$seen_vals = [];
foreach ( $raw_options as $option ) {
if ( ! is_array( $option ) ) {
continue;
}
$value = isset( $option['value'] ) ? sanitize_text_field( (string) $option['value'] ) : '';
$label = isset( $option['label'] ) ? sanitize_text_field( (string) $option['label'] ) : '';
if ( '' === $value && '' !== $label ) {
$value = $label;
}
if ( '' === $label && '' !== $value ) {
$label = $value;
}
if ( '' === $value ) {
continue;
}
if ( isset( $seen_vals[ $value ] ) ) {
return new WP_Error(
'gb_form_duplicate_option',
sprintf(
/* translators: %1$s: field name; %2$s: duplicated option value */
__( 'Field "%1$s" has duplicate option value "%2$s".', 'generateblocks-pro' ),
$field_name,
$value
)
);
}
$seen_vals[ $value ] = true;
$clean[] = [
'value' => $value,
'label' => '' !== $label ? $label : $value,
];
}
if ( empty( $clean ) ) {
return new WP_Error(
'gb_form_empty_options',
sprintf(
/* translators: %s: field name */
__( 'Field "%s" requires at least one option with a non-empty value.', 'generateblocks-pro' ),
$field_name
)
);
}
return $clean;
}
/**
* Sanitize a conditions array.
*
* Conditions with unknown operators or empty trigger fields are dropped
* silently (not fatal — a stale condition referencing a deleted field
* shouldn't block the whole save).
*
* @param array $conditions Raw conditions.
* @return array
*/
private function sanitize_conditions( $conditions ) {
$out = [];
foreach ( $conditions as $condition ) {
if ( ! is_array( $condition ) ) {
continue;
}
$field = sanitize_key( $condition['field'] ?? '' );
$operator = sanitize_key( $condition['operator'] ?? '' );
if ( '' === $field || ! in_array( $operator, self::$allowed_operators, true ) ) {
continue;
}
$out[] = [
'field' => $field,
'operator' => $operator,
'value' => sanitize_text_field( (string) ( $condition['value'] ?? '' ) ),
];
}
return $out;
}
/**
* Record a validation failure for admin-notice rendering on the next page load.
*
* @param int $post_id Post ID.
* @param WP_Error $error Error to record.
*/
private function record_error( $post_id, WP_Error $error ) {
set_transient(
self::NOTICE_TRANSIENT_PREFIX . $post_id,
[
'message' => $error->get_error_message(),
'code' => $error->get_error_code(),
],
MINUTE_IN_SECONDS * 10
);
}
/**
* Render admin notices for saved-content validation errors.
*
* Matches on global $post when editing a form so the notice surfaces on
* the same screen where the user just made the breaking edit.
*/
public function render_admin_notices() {
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen || GenerateBlocks_Pro_Form_Post_Type::POST_TYPE !== $screen->post_type ) {
return;
}
global $post;
if ( ! $post instanceof WP_Post ) {
return;
}
$notice = get_transient( self::NOTICE_TRANSIENT_PREFIX . $post->ID );
if ( empty( $notice['message'] ) ) {
return;
}
printf(
'<div class="notice notice-error"><p><strong>%s</strong> %s</p></div>',
esc_html__( 'Form fields are invalid:', 'generateblocks-pro' ),
esc_html( (string) $notice['message'] )
);
}
}
@@ -0,0 +1,136 @@
<?php
/**
* Form spam protection.
*
* Honeypot and timestamp validation.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Form spam protection class.
*/
class GenerateBlocks_Pro_Form_Spam {
/**
* Maximum age of a form token in seconds.
*
* 1 hour — long enough for a reasonable fill-out window, short enough to
* limit replay. The previous 24h window allowed a single harvested token
* to be reused all day.
*
* @var int
*/
const MAX_AGE_SECONDS = 3600;
/**
* Generate an HMAC-signed timestamp token for embedding in the form.
*
* Signs the triple (timestamp, form_id, post_id) so a harvested token can't
* be replayed against a different form or a different page hosting the same
* form. Format: "timestamp-hmac".
*
* @param int $form_id The form post ID.
* @param int $post_id The post/page ID containing the form.
* @return string The signed timestamp token.
*/
public static function generate_timestamp( $form_id = 0, $post_id = 0 ) {
$time = (string) time();
$hmac = hash_hmac( 'sha256', self::sign_payload( $time, $form_id, $post_id ), wp_salt() );
return $time . '-' . $hmac;
}
/**
* Extract the timestamp portion of a signed token, after verifying the HMAC.
*
* Requires the same (form_id, post_id) used at generation time. Passing
* empty values still verifies — but only tokens generated with empty values
* will pass.
*
* @param string $signed The signed timestamp token ("timestamp-hmac").
* @param int $form_id The form post ID that the token was issued for.
* @param int $post_id The post/page ID the token was issued for.
* @return int|false The timestamp on success, false if the signature is invalid.
*/
public static function verified_timestamp( $signed, $form_id = 0, $post_id = 0 ) {
if ( empty( $signed ) || ! is_string( $signed ) ) {
return false;
}
$parts = explode( '-', $signed, 2 );
if ( 2 !== count( $parts ) ) {
return false;
}
$timestamp = absint( $parts[0] );
if ( 0 === $timestamp ) {
return false;
}
$expected = hash_hmac( 'sha256', self::sign_payload( (string) $timestamp, $form_id, $post_id ), wp_salt() );
if ( ! hash_equals( $expected, $parts[1] ) ) {
return false;
}
return $timestamp;
}
/**
* Check whether an elapsed-time window is valid.
*
* No minimum elapsed-time floor: post-refresh submissions and fast humans
* legitimately land in the same second the token was issued. HMAC + honeypot
* + size cap + origin check carry the anti-bot load.
*
* @param int $timestamp The verified timestamp.
* @return bool True if elapsed time is inside the allowed window.
*/
public static function is_fresh( $timestamp ) {
$elapsed = time() - $timestamp;
return $elapsed >= 0 && $elapsed <= self::MAX_AGE_SECONDS;
}
/**
* Derive the per-render honeypot field name.
*
* Scoped to the timestamp + form + post so two forms on the same render
* don't share a honeypot name, and a bot that scraped one form can't reuse
* the name against another.
*
* @param int $timestamp The verified timestamp.
* @param int $form_id The form post ID.
* @param int $post_id The post ID.
* @return string The expected honeypot field name (e.g. "gb_a1b2c3").
*/
public static function honeypot_field_name( $timestamp, $form_id = 0, $post_id = 0 ) {
$payload = 'hp:' . self::sign_payload( (string) $timestamp, $form_id, $post_id );
$hash = hash_hmac( 'sha256', $payload, wp_salt() );
return 'gb_' . substr( $hash, 0, 6 );
}
/**
* Build a canonical payload string for HMAC signing.
*
* Pipe-delimited and segment values are absint'd, so collisions between
* (form_id=12, post_id=345) and (form_id=1, post_id=2345) etc. are not
* possible — integers can't contain pipes.
*
* @param string $time Timestamp string.
* @param int $form_id Form post ID.
* @param int $post_id Host page ID.
* @return string
*/
private static function sign_payload( $time, $form_id, $post_id ) {
return $time . '|' . absint( $form_id ) . '|' . absint( $post_id );
}
}
File diff suppressed because it is too large Load Diff
@@ -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;
}
}
@@ -0,0 +1,427 @@
<?php
/**
* ActiveCampaign form action.
*
* Subscribes form submissions to an ActiveCampaign list.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* ActiveCampaign form action class.
*/
class GenerateBlocks_Pro_Form_Action_ActiveCampaign {
/**
* Register the provider with the registry.
*/
public static function init() {
generateblocks_pro_register_form_integration(
[
'id' => 'activecampaign',
'label' => __( 'ActiveCampaign', 'generateblocks-pro' ),
'type' => 'email',
'connection' => [
'type' => 'api_key',
'fields' => [
[
'key' => 'api_url',
'label' => __( 'API URL', 'generateblocks-pro' ),
'type' => 'url',
'placeholder' => 'https://youraccount.api-us1.com',
'help' => __( 'Paste the account-specific API URL from ActiveCampaign under Settings > Developer. It should look like https://youraccount.api-us1.com.', 'generateblocks-pro' ),
'required' => true,
'define' => 'GENERATEBLOCKS_PRO_ACTIVECAMPAIGN_API_URL',
],
[
'key' => 'api_key',
'label' => __( 'API Key', 'generateblocks-pro' ),
'type' => 'password',
'help' => __( 'Find your API key in ActiveCampaign under Settings > Developer.', 'generateblocks-pro' ),
'required' => true,
'define' => 'GENERATEBLOCKS_PRO_ACTIVECAMPAIGN_API_KEY',
],
],
'test_callback' => function ( $settings ) {
return self::test_connection(
$settings['api_key'] ?? '',
$settings['api_url'] ?? ''
);
},
],
'destination' => [
'label' => __( 'List', 'generateblocks-pro' ),
'empty_label' => __( '- Select -', 'generateblocks-pro' ),
'refresh_label' => __( 'Refresh', 'generateblocks-pro' ),
'required' => true,
'options_callback' => function () {
return self::fetch_lists( self::get_api_key(), self::get_api_url() );
},
],
'field_map' => [
'label' => __( 'Fields', 'generateblocks-pro' ),
'empty_label' => __( 'Don\'t map', 'generateblocks-pro' ),
'refresh_label' => __( 'Refresh fields', 'generateblocks-pro' ),
'options_callback' => function () {
return self::fetch_fields( self::get_api_key(), self::get_api_url() );
},
],
'subscribe_callback' => [ __CLASS__, 'subscribe' ],
]
);
}
/**
* Subscribe using the generic email-signup settings shape.
*
* @param array $sanitized_data The sanitized form data.
* @param array $settings Email signup settings.
* @param array $context Request context.
* @return true|WP_Error
*/
public static function subscribe( $sanitized_data, $settings, $context = [] ) {
unset( $context );
$api_key = self::get_api_key();
$api_url = self::get_api_url();
if ( empty( $api_key ) || empty( $api_url ) ) {
return new WP_Error( 'no_connection', 'ActiveCampaign API URL and key are not configured.' );
}
$list_id = sanitize_text_field( $settings['destinationId'] ?? '' );
if ( empty( $list_id ) ) {
return new WP_Error( 'no_list', 'ActiveCampaign list not selected.' );
}
if ( ! preg_match( '/^\d{1,20}$/', $list_id ) ) {
return new WP_Error( 'invalid_list', 'Invalid ActiveCampaign list ID.' );
}
$email_field = $settings['emailField'] ?? 'email';
$email = $sanitized_data[ $email_field ]['value'] ?? '';
if ( empty( $email ) || ! is_email( $email ) ) {
return new WP_Error( 'invalid_email', 'Valid email required for ActiveCampaign.' );
}
$contact = [
'email' => $email,
];
$field_values = [];
$field_map = $settings['fieldMap'] ?? [];
foreach ( $field_map as $form_field => $activecampaign_field ) {
$activecampaign_field = sanitize_text_field( $activecampaign_field );
if ( empty( $activecampaign_field ) || ! isset( $sanitized_data[ $form_field ]['value'] ) ) {
continue;
}
$value = GenerateBlocks_Pro_Form_Http::normalize_field_value( $sanitized_data[ $form_field ]['value'] );
if ( '' === $value ) {
continue;
}
if ( in_array( $activecampaign_field, [ 'firstName', 'lastName', 'phone' ], true ) ) {
$contact[ $activecampaign_field ] = $value;
} elseif ( preg_match( '/^\d{1,20}$/', $activecampaign_field ) ) {
$field_values[] = [
'field' => $activecampaign_field,
'value' => $value,
];
}
}
if ( ! empty( $field_values ) ) {
$contact['fieldValues'] = $field_values;
}
$sync = self::request(
'POST',
'/contact/sync',
$api_key,
$api_url,
[
'contact' => $contact,
]
);
if ( is_wp_error( $sync ) ) {
return $sync;
}
$contact_id = $sync['contact']['id'] ?? 0;
if ( ! preg_match( '/^\d{1,20}$/', (string) $contact_id ) ) {
return new WP_Error( 'activecampaign_error', 'ActiveCampaign: contact sync returned no contact ID.' );
}
return self::ensure_list_membership( $api_key, $api_url, (string) $contact_id, $list_id );
}
/**
* Get the stored API key.
*
* @return string
*/
public static function get_api_key() {
return GenerateBlocks_Pro_Form_Integration_Rest::get_api_key_for( 'activecampaign' );
}
/**
* Get the stored API URL.
*
* @return string
*/
public static function get_api_url() {
return GenerateBlocks_Pro_Form_Integration_Rest::get_connection_setting_for( 'activecampaign', 'api_url' );
}
/**
* Fetch lists from ActiveCampaign.
*
* @param string $api_key The API key.
* @param string $api_url The account API URL.
* @return array|WP_Error
*/
public static function fetch_lists( $api_key, $api_url ) {
$response = self::request( 'GET', '/lists?limit=100', $api_key, $api_url );
if ( is_wp_error( $response ) ) {
return $response;
}
if ( empty( $response['lists'] ) || ! is_array( $response['lists'] ) ) {
return [];
}
return array_map(
function ( $list ) {
return [
'id' => $list['id'] ?? '',
'name' => $list['name'] ?? '',
];
},
$response['lists']
);
}
/**
* Fetch standard and custom contact fields from ActiveCampaign.
*
* @param string $api_key The API key.
* @param string $api_url The account API URL.
* @return array|WP_Error
*/
public static function fetch_fields( $api_key, $api_url ) {
$options = [
[
'value' => 'firstName',
'label' => __( 'First name', 'generateblocks-pro' ),
],
[
'value' => 'lastName',
'label' => __( 'Last name', 'generateblocks-pro' ),
],
[
'value' => 'phone',
'label' => __( 'Phone', 'generateblocks-pro' ),
],
];
$response = self::request( 'GET', '/fields?limit=100', $api_key, $api_url );
if ( is_wp_error( $response ) ) {
return $response;
}
if ( empty( $response['fields'] ) || ! is_array( $response['fields'] ) ) {
return $options;
}
foreach ( $response['fields'] as $field ) {
$id = $field['id'] ?? '';
$title = $field['title'] ?? '';
if ( ! preg_match( '/^\d{1,20}$/', (string) $id ) || '' === $title ) {
continue;
}
$options[] = [
'value' => (string) $id,
'label' => $title,
];
}
return $options;
}
/**
* Test the API connection.
*
* @param string $api_key The API key.
* @param string $api_url The account API URL.
* @return true|WP_Error
*/
public static function test_connection( $api_key, $api_url ) {
$response = self::request( 'GET', '/users/me', $api_key, $api_url );
if ( is_wp_error( $response ) ) {
return new WP_Error( 'connection_failed', $response->get_error_message() );
}
return true;
}
/**
* Subscribe a contact to the selected list without re-subscribing opt-outs.
*
* @param string $api_key The API key.
* @param string $api_url The account API URL.
* @param string $contact_id ActiveCampaign contact ID.
* @param string $list_id ActiveCampaign list ID.
* @return true|WP_Error
*/
private static function ensure_list_membership( $api_key, $api_url, $contact_id, $list_id ) {
$memberships = self::request( 'GET', '/contacts/' . $contact_id . '/contactLists', $api_key, $api_url );
if ( is_wp_error( $memberships ) ) {
return $memberships;
}
foreach ( $memberships['contactLists'] ?? [] as $membership ) {
if ( (string) ( $membership['list'] ?? '' ) !== $list_id ) {
continue;
}
if ( '1' === (string) ( $membership['status'] ?? '' ) ) {
return true;
}
return new WP_Error( 'activecampaign_unsubscribed', 'ActiveCampaign: contact is not active on this list.' );
}
$subscribe = self::request(
'POST',
'/contactLists',
$api_key,
$api_url,
[
'contactList' => [
'list' => $list_id,
'contact' => $contact_id,
'status' => 1,
],
]
);
if ( is_wp_error( $subscribe ) ) {
return $subscribe;
}
return true;
}
/**
* Shared ActiveCampaign request helper.
*
* @param string $method HTTP method.
* @param string $path API path.
* @param string $api_key API key.
* @param string $api_url Account API URL.
* @param array|null $body Optional JSON body.
* @return array|WP_Error
*/
private static function request( $method, $path, $api_key, $api_url, $body = null ) {
$base = self::normalize_api_url( $api_url );
if ( '' === $base || '' === trim( (string) $api_key ) ) {
return new WP_Error( 'connection_missing', 'ActiveCampaign API URL and key are required.' );
}
$args = [
'method' => $method,
'timeout' => GenerateBlocks_Pro_Form_Http::DEFAULT_TIMEOUT,
'headers' => [
'Api-Token' => $api_key,
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
];
if ( null !== $body ) {
$args['body'] = wp_json_encode( $body );
}
$response = wp_safe_remote_request( $base . '/' . ltrim( $path, '/' ), $args );
if ( is_wp_error( $response ) ) {
return $response;
}
$error = GenerateBlocks_Pro_Form_Http::response_error( $response, 'ActiveCampaign' );
if ( $error ) {
return $error;
}
$decoded = json_decode( wp_remote_retrieve_body( $response ), true );
return is_array( $decoded ) ? $decoded : [];
}
/**
* Normalize an ActiveCampaign account URL.
*
* @param string $api_url Raw URL.
* @return string Base API URL, including /api/3.
*/
private static function normalize_api_url( $api_url ) {
$api_url = trim( (string) $api_url );
if ( '' === $api_url || ! preg_match( '#^https://#i', $api_url ) ) {
return '';
}
$api_url = rtrim( $api_url, "/ \t\n\r\0\x0B" );
$parts = wp_parse_url( $api_url );
if (
! is_array( $parts )
|| empty( $parts['host'] )
|| ! empty( $parts['user'] )
|| ! empty( $parts['pass'] )
|| ! empty( $parts['query'] )
|| ! empty( $parts['fragment'] )
) {
return '';
}
$path = $parts['path'] ?? '';
if ( ! in_array( $path, [ '', '/', '/api/3', '/api/3/' ], true ) ) {
return '';
}
$base = 'https://' . $parts['host'];
if ( ! empty( $parts['port'] ) ) {
$base .= ':' . (int) $parts['port'];
}
if ( ! filter_var( $base, FILTER_VALIDATE_URL ) ) {
return '';
}
return $base . '/api/3';
}
}
@@ -0,0 +1,367 @@
<?php
/**
* Brevo form action.
*
* Subscribes form submissions to a Brevo list.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Brevo form action class.
*/
class GenerateBlocks_Pro_Form_Action_Brevo {
/**
* API base URL.
*
* @var string
*/
const API_BASE = 'https://api.brevo.com/v3';
/**
* Register the provider with the registry.
*/
public static function init() {
generateblocks_pro_register_form_integration(
[
'id' => 'brevo',
'label' => __( 'Brevo', 'generateblocks-pro' ),
'type' => 'email',
'connection' => [
'type' => 'api_key',
'fields' => [
[
'key' => 'api_key',
'label' => __( 'API Key', 'generateblocks-pro' ),
'type' => 'password',
'help' => __( 'Find your API key in Brevo under SMTP & API.', 'generateblocks-pro' ),
'required' => true,
'define' => 'GENERATEBLOCKS_PRO_BREVO_API_KEY',
],
],
'test_callback' => function ( $settings ) {
return self::test_connection( $settings['api_key'] ?? '' );
},
],
'destination' => [
'label' => __( 'List', 'generateblocks-pro' ),
'empty_label' => __( '- Select -', 'generateblocks-pro' ),
'refresh_label' => __( 'Refresh', 'generateblocks-pro' ),
'required' => true,
'options_callback' => function () {
return self::fetch_lists( self::get_api_key() );
},
],
'field_map' => [
'label' => __( 'Attributes', 'generateblocks-pro' ),
'empty_label' => __( 'Don\'t map', 'generateblocks-pro' ),
'refresh_label' => __( 'Refresh attributes', 'generateblocks-pro' ),
'options_callback' => function () {
return self::fetch_attributes( self::get_api_key() );
},
],
'subscribe_callback' => [ __CLASS__, 'subscribe' ],
]
);
}
/**
* Subscribe using the generic email-signup settings shape.
*
* @param array $sanitized_data The sanitized form data.
* @param array $settings Email signup settings.
* @param array $context Request context.
* @return true|WP_Error
*/
public static function subscribe( $sanitized_data, $settings, $context = [] ) {
unset( $context );
$api_key = self::get_api_key();
if ( empty( $api_key ) ) {
return new WP_Error( 'no_api_key', 'Brevo API key not configured.' );
}
$list_id_raw = sanitize_text_field( (string) ( $settings['destinationId'] ?? '' ) );
if ( '' === $list_id_raw ) {
return new WP_Error( 'no_list', 'Brevo list not selected.' );
}
if ( ! preg_match( '/^\d{1,18}$/', $list_id_raw ) ) {
return new WP_Error( 'invalid_list', 'Invalid Brevo list ID.' );
}
$list_id = (int) $list_id_raw;
$email_field = $settings['emailField'] ?? 'email';
$email = $sanitized_data[ $email_field ]['value'] ?? '';
if ( empty( $email ) || ! is_email( $email ) ) {
return new WP_Error( 'invalid_email', 'Valid email required for Brevo.' );
}
$existing_contact = self::fetch_contact_by_email( $email, $api_key );
if ( is_wp_error( $existing_contact ) ) {
$error_data = $existing_contact->get_error_data();
$status = is_array( $error_data ) && isset( $error_data['status'] )
? (int) $error_data['status']
: 0;
if ( 404 !== $status ) {
return $existing_contact;
}
} elseif ( self::contact_is_unsubscribed( $existing_contact, $list_id ) ) {
return true;
}
$attributes = [];
$field_map = $settings['fieldMap'] ?? [];
foreach ( $field_map as $form_field => $attribute ) {
$attribute = strtoupper( sanitize_text_field( $attribute ) );
if ( empty( $attribute ) || ! preg_match( '/^[A-Z0-9_]{1,64}$/', $attribute ) || ! isset( $sanitized_data[ $form_field ]['value'] ) ) {
continue;
}
$value = GenerateBlocks_Pro_Form_Http::normalize_field_value( $sanitized_data[ $form_field ]['value'] );
if ( '' !== $value ) {
$attributes[ $attribute ] = $value;
}
}
$body = [
'email' => $email,
'listIds' => [ $list_id ],
'updateEnabled' => true,
];
if ( ! empty( $attributes ) ) {
$body['attributes'] = $attributes;
}
$response = self::request( 'POST', '/contacts', $api_key, $body );
if ( is_wp_error( $response ) ) {
return $response;
}
return true;
}
/**
* Fetch an existing contact by email.
*
* @param string $email Contact email address.
* @param string $api_key Brevo API key.
* @return array|WP_Error
*/
private static function fetch_contact_by_email( $email, $api_key ) {
return self::request(
'GET',
'/contacts/' . rawurlencode( $email ) . '?identifierType=email_id',
$api_key
);
}
/**
* Check whether a Brevo contact should not be re-subscribed.
*
* @param array $contact Contact data from Brevo.
* @param int $list_id Destination list ID.
* @return bool
*/
private static function contact_is_unsubscribed( $contact, $list_id ) {
if ( ! is_array( $contact ) ) {
return false;
}
if ( self::is_truthy( $contact['emailBlacklisted'] ?? false ) ) {
return true;
}
$list_unsubscribed = $contact['listUnsubscribed'] ?? [];
if ( ! is_array( $list_unsubscribed ) ) {
return false;
}
foreach ( $list_unsubscribed as $unsubscribed_list_id ) {
if ( (int) $unsubscribed_list_id === $list_id ) {
return true;
}
}
return false;
}
/**
* Interpret common truthy values returned by APIs or stored test fixtures.
*
* @param mixed $value Value to test.
* @return bool
*/
private static function is_truthy( $value ) {
return true === $value || 1 === $value || '1' === $value || 'true' === $value;
}
/**
* Get the stored API key.
*
* @return string
*/
public static function get_api_key() {
return GenerateBlocks_Pro_Form_Integration_Rest::get_api_key_for( 'brevo' );
}
/**
* Fetch contact lists from Brevo.
*
* @param string $api_key The API key.
* @return array|WP_Error
*/
public static function fetch_lists( $api_key ) {
$limit = 50;
$offset = 0;
$lists = [];
$more = true;
while ( $more ) {
$response = self::request(
'GET',
'/contacts/lists?limit=' . $limit . '&offset=' . $offset . '&sort=asc',
$api_key
);
if ( is_wp_error( $response ) ) {
return $response;
}
if ( empty( $response['lists'] ) || ! is_array( $response['lists'] ) ) {
break;
}
$lists = array_merge( $lists, $response['lists'] );
$count = isset( $response['count'] ) ? (int) $response['count'] : count( $lists );
$offset = $offset + $limit;
$more = count( $lists ) < $count && $offset < 1000;
}
return array_map(
function ( $list ) {
return [
'id' => $list['id'] ?? '',
'name' => $list['name'] ?? '',
'count' => $list['uniqueSubscribers'] ?? null,
];
},
$lists
);
}
/**
* Fetch contact attributes from Brevo.
*
* @param string $api_key The API key.
* @return array|WP_Error
*/
public static function fetch_attributes( $api_key ) {
$response = self::request( 'GET', '/contacts/attributes', $api_key );
if ( is_wp_error( $response ) ) {
return $response;
}
if ( empty( $response['attributes'] ) || ! is_array( $response['attributes'] ) ) {
return [];
}
$options = [];
foreach ( $response['attributes'] as $attribute ) {
$name = strtoupper( sanitize_text_field( $attribute['name'] ?? '' ) );
$category = sanitize_key( $attribute['category'] ?? '' );
if ( '' === $name || 'EMAIL' === $name || ! in_array( $category, [ 'normal', 'category', 'transactional' ], true ) ) {
continue;
}
$options[] = [
'value' => $name,
'label' => $name,
];
}
return $options;
}
/**
* Test the API connection.
*
* @param string $api_key The API key.
* @return true|WP_Error
*/
public static function test_connection( $api_key ) {
$response = self::request( 'GET', '/account', $api_key );
if ( is_wp_error( $response ) ) {
return new WP_Error( 'connection_failed', $response->get_error_message() );
}
return true;
}
/**
* Shared Brevo request helper.
*
* @param string $method HTTP method.
* @param string $path API path.
* @param string $api_key API key.
* @param array|null $body Optional JSON body.
* @return array|WP_Error
*/
private static function request( $method, $path, $api_key, $body = null ) {
if ( '' === trim( (string) $api_key ) ) {
return new WP_Error( 'connection_missing', 'Brevo API key is required.' );
}
$args = [
'method' => $method,
'timeout' => GenerateBlocks_Pro_Form_Http::DEFAULT_TIMEOUT,
'headers' => [
'api-key' => $api_key,
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
];
if ( null !== $body ) {
$args['body'] = wp_json_encode( $body );
}
$response = wp_safe_remote_request( self::API_BASE . $path, $args );
if ( is_wp_error( $response ) ) {
return $response;
}
$error = GenerateBlocks_Pro_Form_Http::response_error( $response, 'Brevo' );
if ( $error ) {
return $error;
}
$decoded = json_decode( wp_remote_retrieve_body( $response ), true );
return is_array( $decoded ) ? $decoded : [];
}
}
@@ -0,0 +1,43 @@
<?php
/**
* Built-in form integration loader.
*
* Loads the official Pro integration providers (Mailchimp, Kit, MailerLite,
* ActiveCampaign, Brevo, Webhook) on the
* `generateblocks_form_register_integrations` hook. Built-ins use the same
* public registration entry point as third-party integrations, so the seam
* between Pro and any future connectors plugin stays a directory move rather
* than a code rewrite.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Priority 5 so built-ins run first; third-party callbacks at default priority
// 10 can then re-register the same provider ID to replace the built-in, which
// is the documented override contract in class-form-integration-registry.php.
add_action( 'generateblocks_form_register_integrations', 'generateblocks_pro_load_builtin_integrations', 5 );
/**
* Require and initialize the built-in integration providers.
*/
function generateblocks_pro_load_builtin_integrations() {
$dir = GENERATEBLOCKS_PRO_DIR . 'includes/form/integrations/';
require_once $dir . 'mailchimp.php';
require_once $dir . 'convertkit.php';
require_once $dir . 'mailerlite.php';
require_once $dir . 'activecampaign.php';
require_once $dir . 'brevo.php';
require_once $dir . 'webhook.php';
GenerateBlocks_Pro_Form_Action_Mailchimp::init();
GenerateBlocks_Pro_Form_Action_ConvertKit::init();
GenerateBlocks_Pro_Form_Action_MailerLite::init();
GenerateBlocks_Pro_Form_Action_ActiveCampaign::init();
GenerateBlocks_Pro_Form_Action_Brevo::init();
GenerateBlocks_Pro_Form_Integration_Webhook::init();
}
@@ -0,0 +1,380 @@
<?php
/**
* ConvertKit form action.
*
* Subscribes form submissions to a ConvertKit form or tag.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* ConvertKit form action class.
*/
class GenerateBlocks_Pro_Form_Action_ConvertKit {
/**
* API base URL for v4. Kit v4 API keys authenticate via the
* X-Kit-Api-Key header; Bearer is reserved for OAuth tokens.
*
* @var string
*/
const API_BASE = 'https://api.kit.com/v4';
/**
* Register the provider with the registry.
*/
public static function init() {
generateblocks_pro_register_form_integration(
[
'id' => 'convertkit',
'label' => __( 'Kit (ConvertKit)', 'generateblocks-pro' ),
'type' => 'email',
'connection' => [
'type' => 'api_key',
'fields' => [
[
'key' => 'api_key',
'label' => __( 'API Key', 'generateblocks-pro' ),
'type' => 'password',
'help' => __( 'Find your v4 API key in Kit under Settings > Developer.', 'generateblocks-pro' ),
'required' => true,
'define' => 'GENERATEBLOCKS_PRO_CONVERTKIT_API_KEY',
],
],
'test_callback' => function ( $settings ) {
return self::test_connection( $settings['api_key'] ?? '' );
},
],
'destination' => [
'label' => __( 'Form', 'generateblocks-pro' ),
'empty_label' => __( '- Select -', 'generateblocks-pro' ),
'refresh_label' => __( 'Refresh', 'generateblocks-pro' ),
'required' => true,
'options_callback' => function () {
return self::fetch_forms( self::get_api_key() );
},
],
'field_map' => [
'label' => __( 'Fields', 'generateblocks-pro' ),
'empty_label' => __( 'Don\'t map', 'generateblocks-pro' ),
'refresh_label' => __( 'Refresh fields', 'generateblocks-pro' ),
'options_callback' => function () {
return self::fetch_custom_fields( self::get_api_key() );
},
],
'subscribe_callback' => [ __CLASS__, 'subscribe' ],
]
);
}
/**
* Subscribe using the generic email-signup settings shape.
*
* @param array $sanitized_data The sanitized form data.
* @param array $settings Email signup settings.
* @param array $context Request context.
* @return true|WP_Error
*/
public static function subscribe( $sanitized_data, $settings, $context = [] ) {
$api_key = self::get_api_key();
if ( empty( $api_key ) ) {
return new WP_Error( 'no_api_key', 'ConvertKit API key not configured.' );
}
$form_id = sanitize_text_field( (string) ( $settings['destinationId'] ?? '' ) );
if ( '' === $form_id ) {
return new WP_Error( 'no_form', 'ConvertKit form not selected.' );
}
if ( ! preg_match( '/^\d{1,20}$/', $form_id ) ) {
return new WP_Error( 'invalid_form', 'Invalid ConvertKit form ID.' );
}
$email_field = $settings['emailField'] ?? 'email';
$email = $sanitized_data[ $email_field ]['value'] ?? '';
if ( empty( $email ) || ! is_email( $email ) ) {
return new WP_Error( 'invalid_email', 'Valid email required for ConvertKit.' );
}
$body = [
'email_address' => $email,
];
// Map subscriber fields.
$field_map = $settings['fieldMap'] ?? [];
$fields = [];
foreach ( $field_map as $form_field => $ck_field ) {
$ck_field = sanitize_text_field( $ck_field );
if ( empty( $ck_field ) || ! preg_match( '/^[A-Za-z0-9_]{1,100}$/', $ck_field ) || ! isset( $sanitized_data[ $form_field ]['value'] ) ) {
continue;
}
$value = GenerateBlocks_Pro_Form_Http::normalize_field_value( $sanitized_data[ $form_field ]['value'] );
if ( '' !== $value ) {
if ( 'first_name' === $ck_field ) {
$body['first_name'] = $value;
} else {
$fields[ $ck_field ] = $value;
}
}
}
if ( ! empty( $fields ) ) {
$body['fields'] = $fields;
}
// Kit v4 keeps subscriber profile updates separate from form association:
// first create/update the subscriber, then add that subscriber to the
// selected form with the required referrer payload.
$create = self::request( 'POST', '/subscribers', $api_key, $body );
if ( is_wp_error( $create ) ) {
return $create;
}
$subscriber_id = $create['subscriber']['id'] ?? 0;
if ( ! is_scalar( $subscriber_id ) || ! preg_match( '/^\d{1,20}$/', (string) $subscriber_id ) ) {
return new WP_Error( 'convertkit_error', 'ConvertKit: subscriber creation returned invalid id.' );
}
$assoc = self::request(
'POST',
'/forms/' . $form_id . '/subscribers/' . (string) $subscriber_id,
$api_key,
[
'referrer' => self::get_referrer( $context ),
]
);
if ( is_wp_error( $assoc ) ) {
return $assoc;
}
return true;
}
/**
* Get a safe referrer value for Kit form association.
*
* @param array $context Request context.
* @return string
*/
private static function get_referrer( $context ) {
$page_url = isset( $context['page_url'] ) && is_scalar( $context['page_url'] )
? trim( (string) $context['page_url'] )
: '';
return filter_var( $page_url, FILTER_VALIDATE_URL ) ? $page_url : '';
}
/**
* Shared Kit v4 request helper.
*
* Normalizes auth, error parsing, and logging across Kit endpoints.
*
* @param string $method HTTP method (GET/POST).
* @param string $path Path beneath API_BASE (e.g. /subscribers).
* @param string $key API key.
* @param array|null $body Optional JSON body.
* @return array|WP_Error Decoded JSON body on success, WP_Error on failure.
*/
private static function request( $method, $path, $key, $body = null ) {
$endpoint = self::API_BASE . $path;
$args = [
'method' => $method,
'timeout' => GenerateBlocks_Pro_Form_Http::DEFAULT_TIMEOUT,
'headers' => [
'X-Kit-Api-Key' => $key,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
];
if ( null !== $body ) {
$args['body'] = wp_json_encode( $body );
}
$response = wp_safe_remote_request( $endpoint, $args );
if ( is_wp_error( $response ) ) {
return $response;
}
$status = wp_remote_retrieve_response_code( $response );
$raw_body = wp_remote_retrieve_body( $response );
$decoded = json_decode( $raw_body, true );
if ( $status >= 200 && $status < 300 ) {
return is_array( $decoded ) ? $decoded : [];
}
$detail = '';
if ( is_array( $decoded ) ) {
if ( ! empty( $decoded['errors'] ) && is_array( $decoded['errors'] ) ) {
$detail = implode( '; ', array_filter( array_map( 'strval', $decoded['errors'] ) ) );
} elseif ( ! empty( $decoded['error'] ) && is_string( $decoded['error'] ) ) {
$detail = $decoded['error'];
} elseif ( ! empty( $decoded['message'] ) && is_string( $decoded['message'] ) ) {
$detail = $decoded['message'];
}
}
if ( '' === $detail ) {
$detail = 'HTTP ' . $status;
}
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
// Log status + parsed error detail only. Raw body would echo the
// submitted email back into the PHP error log on every auth/validation
// failure.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'GenerateBlocks Form ConvertKit ' . $method . ' ' . $path . ' (status ' . $status . '): ' . sanitize_text_field( $detail ) );
}
return new WP_Error( 'convertkit_error', 'ConvertKit: ' . sanitize_text_field( $detail ) );
}
/**
* Get the stored API key.
*
* @return string
*/
public static function get_api_key() {
return GenerateBlocks_Pro_Form_Integration_Rest::get_api_key_for( 'convertkit' );
}
/**
* Fetch forms from ConvertKit.
*
* @param string $api_key The API key.
* @return array|WP_Error
*/
public static function fetch_forms( $api_key ) {
$body = self::request( 'GET', '/forms', $api_key );
if ( is_wp_error( $body ) ) {
return $body;
}
if ( empty( $body['forms'] ) || ! is_array( $body['forms'] ) ) {
return [];
}
// Drop Kit's auto-created system forms ("Creator Network", "Creator
// Profile", and similar variants). They are surfaced by /v4/forms but
// cannot be used as subscribe targets. Substring match on the name so
// minor wording changes ("Creator Network Recommendations" vs just
// "Creator Network") still catch.
$blocked_name_fragments = [ 'creator network', 'creator profile' ];
$forms = array_filter(
$body['forms'],
function ( $form ) use ( $blocked_name_fragments ) {
$name = strtolower( (string) ( $form['name'] ?? '' ) );
foreach ( $blocked_name_fragments as $fragment ) {
if ( false !== strpos( $name, $fragment ) ) {
return false;
}
}
return true;
}
);
return array_values(
array_map(
function ( $form ) {
return [
'id' => $form['id'],
'name' => $form['name'],
];
},
$forms
)
);
}
/**
* Fetch custom fields from ConvertKit.
*
* @param string $api_key The API key.
* @return array|WP_Error
*/
public static function fetch_custom_fields( $api_key ) {
$response = self::request( 'GET', '/custom_fields?per_page=1000', $api_key );
if ( is_wp_error( $response ) ) {
return $response;
}
$options = [
[
'value' => 'first_name',
'label' => __( 'First name (first_name)', 'generateblocks-pro' ),
],
];
if ( empty( $response['custom_fields'] ) || ! is_array( $response['custom_fields'] ) ) {
return $options;
}
foreach ( $response['custom_fields'] as $field ) {
$key = sanitize_text_field( $field['key'] ?? '' );
$label = sanitize_text_field( $field['label'] ?? $key );
if ( '' === $key || 'first_name' === $key || ! preg_match( '/^[A-Za-z0-9_]{1,100}$/', $key ) ) {
continue;
}
$options[] = [
'value' => $key,
'label' => $label . ' (' . $key . ')',
];
}
return $options;
}
/**
* Test the API connection.
*
* @param string $api_key The API key.
* @return true|WP_Error
*/
public static function test_connection( $api_key ) {
$response = wp_safe_remote_get(
self::API_BASE . '/account',
[
'timeout' => GenerateBlocks_Pro_Form_Http::DEFAULT_TIMEOUT,
'headers' => [
'X-Kit-Api-Key' => $api_key,
],
]
);
if ( is_wp_error( $response ) ) {
return new WP_Error( 'connection_failed', 'Could not connect to ConvertKit. Check your API key.' );
}
if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
return new WP_Error( 'auth_failed', 'Invalid Kit API key. Kit v4 API keys are required — legacy v3 "API Secret" values will not authenticate.' );
}
return true;
}
}
@@ -0,0 +1,367 @@
<?php
/**
* Mailchimp form action.
*
* Subscribes form submissions to a Mailchimp audience.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Mailchimp form action class.
*/
class GenerateBlocks_Pro_Form_Action_Mailchimp {
/**
* Register the provider with the registry.
*/
public static function init() {
generateblocks_pro_register_form_integration(
[
'id' => 'mailchimp',
'label' => __( 'Mailchimp', 'generateblocks-pro' ),
'type' => 'email',
'connection' => [
'type' => 'api_key',
'fields' => [
[
'key' => 'api_key',
'label' => __( 'API Key', 'generateblocks-pro' ),
'type' => 'password',
'help' => __( 'Find your API key in Mailchimp under Account > Extras > API Keys.', 'generateblocks-pro' ),
'required' => true,
'define' => 'GENERATEBLOCKS_PRO_MAILCHIMP_API_KEY',
],
],
'test_callback' => function ( $settings ) {
return self::test_connection( $settings['api_key'] ?? '' );
},
],
'destination' => [
'label' => __( 'Audience', 'generateblocks-pro' ),
'empty_label' => __( '- Select -', 'generateblocks-pro' ),
'refresh_label' => __( 'Refresh', 'generateblocks-pro' ),
'required' => true,
'options_callback' => function () {
return self::fetch_audiences( self::get_api_key() );
},
],
'field_map' => [
'label' => __( 'Merge fields', 'generateblocks-pro' ),
'empty_label' => __( 'Don\'t map', 'generateblocks-pro' ),
'refresh_label' => __( 'Refresh fields', 'generateblocks-pro' ),
'depends_on_destination' => true,
'options_callback' => function ( $settings ) {
$audience_id = sanitize_text_field( $settings['destinationId'] ?? '' );
if ( empty( $audience_id ) ) {
return [];
}
$fields = self::fetch_merge_fields( self::get_api_key(), $audience_id );
if ( is_wp_error( $fields ) ) {
return $fields;
}
return array_map(
function ( $field ) {
$tag = $field['tag'] ?? '';
return [
'value' => $tag,
'label' => ( $field['name'] ?? $tag ) . ' (' . $tag . ')',
];
},
$fields
);
},
],
'supports_double_optin' => true,
'subscribe_callback' => [ __CLASS__, 'subscribe' ],
]
);
}
/**
* Subscribe using the generic email-signup settings shape.
*
* @param array $sanitized_data The sanitized form data.
* @param array $settings Email signup settings.
* @param array $context Request context.
* @return true|WP_Error
*/
public static function subscribe( $sanitized_data, $settings, $context = [] ) {
unset( $context );
$api_key = self::get_api_key();
if ( empty( $api_key ) ) {
return new WP_Error( 'no_api_key', 'Mailchimp API key not configured.' );
}
$audience_id = sanitize_text_field( $settings['destinationId'] ?? '' );
if ( empty( $audience_id ) ) {
return new WP_Error( 'no_audience', 'Mailchimp audience not selected.' );
}
// Mailchimp audience IDs are 10-char hex strings. Strict shape match
// catches admin typos before the API call rather than letting them
// surface as opaque "404 not found" failures from Mailchimp.
if ( ! preg_match( '/^[a-f0-9]{10}$/i', $audience_id ) ) {
return new WP_Error( 'invalid_audience', 'Invalid Mailchimp audience ID.' );
}
$email_field = $settings['emailField'] ?? 'email';
$email = $sanitized_data[ $email_field ]['value'] ?? '';
if ( empty( $email ) || ! is_email( $email ) ) {
return new WP_Error( 'invalid_email', 'Valid email required for Mailchimp.' );
}
// Build merge fields from field mapping.
$merge_fields = [];
$field_map = $settings['fieldMap'] ?? [];
foreach ( $field_map as $form_field => $merge_tag ) {
$merge_tag = sanitize_text_field( $merge_tag );
// Mailchimp merge tags are uppercase alphanumeric + underscores (e.g. FNAME).
if ( ! empty( $merge_tag ) && preg_match( '/^[A-Z0-9_]{1,10}$/', $merge_tag ) && isset( $sanitized_data[ $form_field ]['value'] ) ) {
$value = GenerateBlocks_Pro_Form_Http::normalize_field_value( $sanitized_data[ $form_field ]['value'] );
if ( '' !== $value ) {
$merge_fields[ $merge_tag ] = $value;
}
}
}
$dc = self::get_data_center( $api_key );
$subscriber_hash = md5( strtolower( $email ) );
$endpoint = 'https://' . $dc . '.api.mailchimp.com/3.0/lists/' . $audience_id . '/members/' . $subscriber_hash;
$double_optin = $settings['doubleOptin'] ?? true;
// Mailchimp's PUT "Add or update list member" is idempotent:
// - new subscriber → created with status = status_if_new
// - existing member → status left alone, merge_fields updated
// This matters because the previous POST endpoint silently dropped
// merge-field changes for returning contacts (Mailchimp returned
// "Member Exists" which we treated as success).
//
// We deliberately omit `status`; sending it would re-subscribe users
// who had unsubscribed, which is both rude and a CAN-SPAM risk.
$body = [
'email_address' => $email,
'status_if_new' => $double_optin ? 'pending' : 'subscribed',
];
if ( ! empty( $merge_fields ) ) {
$body['merge_fields'] = $merge_fields;
}
$response = wp_safe_remote_request(
$endpoint,
[
'method' => 'PUT',
'timeout' => GenerateBlocks_Pro_Form_Http::DEFAULT_TIMEOUT,
'headers' => [
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode( $body ),
]
);
if ( is_wp_error( $response ) ) {
return $response;
}
$status_code = wp_remote_retrieve_response_code( $response );
// PUT returns 200 for both add and update. 400 with title "Member In
// Compliance State" means the address previously unsubscribed — the
// right behavior is to not silently re-subscribe them.
if ( 200 !== $status_code ) {
$response_body = json_decode( wp_remote_retrieve_body( $response ), true );
$error_detail = $response_body['detail'] ?? 'Unknown error';
return new WP_Error( 'mailchimp_error', 'Mailchimp: ' . sanitize_text_field( $error_detail ) );
}
return true;
}
/**
* Get the stored API key.
*
* @return string
*/
public static function get_api_key() {
return GenerateBlocks_Pro_Form_Integration_Rest::get_api_key_for( 'mailchimp' );
}
/**
* Extract the data center from a Mailchimp API key.
*
* @param string $api_key The API key (format: key-dc).
* @return string The data center identifier.
*/
private static function get_data_center( $api_key ) {
// Mailchimp keys are formatted as "key-dc" where dc is after the last dash.
$dash_pos = strrpos( $api_key, '-' );
if ( false === $dash_pos ) {
return 'us1';
}
$dc = substr( $api_key, $dash_pos + 1 );
$dc = $dc ? $dc : 'us1';
// Validate data center format to prevent SSRF via crafted API keys.
if ( ! preg_match( '/^[a-z]{2}\d+$/i', $dc ) ) {
return 'us1';
}
return $dc;
}
/**
* Fetch audiences (lists) from Mailchimp.
*
* @param string $api_key The API key.
* @return array|WP_Error
*/
public static function fetch_audiences( $api_key ) {
$dc = self::get_data_center( $api_key );
$endpoint = 'https://' . $dc . '.api.mailchimp.com/3.0/lists?count=100&fields=lists.id,lists.name,lists.stats.member_count';
$response = wp_safe_remote_get(
$endpoint,
[
'timeout' => GenerateBlocks_Pro_Form_Http::DEFAULT_TIMEOUT,
'headers' => [
'Authorization' => 'Bearer ' . $api_key,
],
]
);
if ( is_wp_error( $response ) ) {
return $response;
}
$error = GenerateBlocks_Pro_Form_Http::response_error( $response, 'Mailchimp' );
if ( $error ) {
return $error;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( empty( $body['lists'] ) ) {
return [];
}
return array_map(
function ( $list ) {
return [
'id' => $list['id'],
'name' => $list['name'],
'count' => $list['stats']['member_count'] ?? 0,
];
},
$body['lists']
);
}
/**
* Fetch merge fields for an audience.
*
* @param string $api_key The API key.
* @param string $audience_id The audience ID.
* @return array|WP_Error
*/
public static function fetch_merge_fields( $api_key, $audience_id ) {
$audience_id = sanitize_text_field( $audience_id );
if ( ! preg_match( '/^[a-f0-9]{10}$/i', $audience_id ) ) {
return new WP_Error( 'invalid_audience', 'Invalid audience ID.' );
}
$dc = self::get_data_center( $api_key );
$endpoint = 'https://' . $dc . '.api.mailchimp.com/3.0/lists/' . $audience_id . '/merge-fields?count=100';
$response = wp_safe_remote_get(
$endpoint,
[
'timeout' => GenerateBlocks_Pro_Form_Http::DEFAULT_TIMEOUT,
'headers' => [
'Authorization' => 'Bearer ' . $api_key,
],
]
);
if ( is_wp_error( $response ) ) {
return $response;
}
$error = GenerateBlocks_Pro_Form_Http::response_error( $response, 'Mailchimp' );
if ( $error ) {
return $error;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( empty( $body['merge_fields'] ) ) {
return [];
}
return array_map(
function ( $field ) {
return [
'tag' => $field['tag'],
'name' => $field['name'],
'type' => $field['type'],
];
},
$body['merge_fields']
);
}
/**
* Test the API connection.
*
* @param string $api_key The API key.
* @return true|WP_Error
*/
public static function test_connection( $api_key ) {
$dc = self::get_data_center( $api_key );
$endpoint = 'https://' . $dc . '.api.mailchimp.com/3.0/ping';
$response = wp_safe_remote_get(
$endpoint,
[
'timeout' => GenerateBlocks_Pro_Form_Http::DEFAULT_TIMEOUT,
'headers' => [
'Authorization' => 'Bearer ' . $api_key,
],
]
);
if ( is_wp_error( $response ) ) {
return new WP_Error( 'connection_failed', 'Could not reach Mailchimp.' );
}
if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
return new WP_Error( 'auth_failed', 'Invalid Mailchimp API key.' );
}
return true;
}
}
@@ -0,0 +1,308 @@
<?php
/**
* MailerLite form action.
*
* Subscribes form submissions to a MailerLite group.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* MailerLite form action class.
*/
class GenerateBlocks_Pro_Form_Action_MailerLite {
/**
* API base URL.
*
* @var string
*/
const API_BASE = 'https://connect.mailerlite.com/api';
/**
* Shared request args for MailerLite.
*
* MailerLite's edge (Cloudflare) rejects WP's default `WordPress/X.Y; <url>`
* User-Agent on some endpoints (notably /groups) with a 403 HTML page, while
* others (/subscribers) pass. Set an explicit identifying UA so every call
* gets through consistently.
*
* @param string $api_key API key.
* @param array $extra_headers Additional headers to merge.
* @return array
*/
private static function request_args( $api_key, $extra_headers = [] ) {
return [
'timeout' => GenerateBlocks_Pro_Form_Http::DEFAULT_TIMEOUT,
'user-agent' => 'GenerateBlocks-Pro/' . ( defined( 'GENERATEBLOCKS_PRO_VERSION' ) ? GENERATEBLOCKS_PRO_VERSION : '0.0.0' ),
'headers' => array_merge(
[
'Authorization' => 'Bearer ' . $api_key,
'Accept' => 'application/json',
],
$extra_headers
),
];
}
/**
* Register the provider with the registry.
*/
public static function init() {
generateblocks_pro_register_form_integration(
[
'id' => 'mailerlite',
'label' => __( 'MailerLite', 'generateblocks-pro' ),
'type' => 'email',
'connection' => [
'type' => 'api_key',
'fields' => [
[
'key' => 'api_key',
'label' => __( 'API Key', 'generateblocks-pro' ),
'type' => 'password',
'help' => __( 'Find your API token in MailerLite under Integrations > API.', 'generateblocks-pro' ),
'required' => true,
'define' => 'GENERATEBLOCKS_PRO_MAILERLITE_API_KEY',
],
],
'test_callback' => function ( $settings ) {
return self::test_connection( $settings['api_key'] ?? '' );
},
],
'destination' => [
'label' => __( 'Group', 'generateblocks-pro' ),
'empty_label' => __( '- Select -', 'generateblocks-pro' ),
'refresh_label' => __( 'Refresh', 'generateblocks-pro' ),
'required' => true,
'options_callback' => function () {
return self::fetch_groups( self::get_api_key() );
},
],
'field_map' => [
'label' => __( 'Fields', 'generateblocks-pro' ),
'empty_label' => __( 'Don\'t map', 'generateblocks-pro' ),
'refresh_label' => __( 'Refresh fields', 'generateblocks-pro' ),
'options_callback' => function () {
return self::fetch_fields( self::get_api_key() );
},
],
'subscribe_callback' => [ __CLASS__, 'subscribe' ],
]
);
}
/**
* Subscribe using the generic email-signup settings shape.
*
* @param array $sanitized_data The sanitized form data.
* @param array $settings Email signup settings.
* @param array $context Request context.
* @return true|WP_Error
*/
public static function subscribe( $sanitized_data, $settings, $context = [] ) {
unset( $context );
$api_key = self::get_api_key();
if ( empty( $api_key ) ) {
return new WP_Error( 'no_api_key', 'MailerLite API key not configured.' );
}
$group_id = sanitize_text_field( $settings['destinationId'] ?? '' );
if ( empty( $group_id ) ) {
return new WP_Error( 'no_group', 'MailerLite group not selected.' );
}
// MailerLite group IDs are numeric strings — validate to prevent path manipulation.
if ( ! preg_match( '/^\d{1,20}$/', $group_id ) ) {
return new WP_Error( 'invalid_group', 'Invalid MailerLite group ID.' );
}
$email_field = $settings['emailField'] ?? 'email';
$email = $sanitized_data[ $email_field ]['value'] ?? '';
if ( empty( $email ) || ! is_email( $email ) ) {
return new WP_Error( 'invalid_email', 'Valid email required for MailerLite.' );
}
$body = [
'email' => $email,
];
// Map name fields.
$field_map = $settings['fieldMap'] ?? [];
$fields = [];
foreach ( $field_map as $form_field => $ml_field ) {
$ml_field = sanitize_text_field( $ml_field );
if ( empty( $ml_field ) || ! preg_match( '/^[A-Za-z0-9_]{1,64}$/', $ml_field ) || ! isset( $sanitized_data[ $form_field ]['value'] ) ) {
continue;
}
$value = GenerateBlocks_Pro_Form_Http::normalize_field_value( $sanitized_data[ $form_field ]['value'] );
if ( '' !== $value ) {
$fields[ $ml_field ] = $value;
}
}
if ( ! empty( $fields ) ) {
$body['fields'] = $fields;
}
$body['groups'] = [ $group_id ];
$response = wp_safe_remote_post(
self::API_BASE . '/subscribers',
array_merge(
self::request_args( $api_key, [ 'Content-Type' => 'application/json' ] ),
[ 'body' => wp_json_encode( $body ) ]
)
);
if ( is_wp_error( $response ) ) {
return $response;
}
$status_code = wp_remote_retrieve_response_code( $response );
// 200 = updated existing, 201 = new subscriber.
if ( ! in_array( $status_code, [ 200, 201 ], true ) ) {
$response_body = json_decode( wp_remote_retrieve_body( $response ), true );
return new WP_Error(
'mailerlite_error',
'MailerLite: ' . sanitize_text_field( $response_body['message'] ?? 'Unknown error' )
);
}
return true;
}
/**
* Get the stored API key.
*
* @return string
*/
public static function get_api_key() {
return GenerateBlocks_Pro_Form_Integration_Rest::get_api_key_for( 'mailerlite' );
}
/**
* Fetch groups from MailerLite.
*
* @param string $api_key The API key.
* @return array|WP_Error
*/
public static function fetch_groups( $api_key ) {
$response = wp_safe_remote_get(
self::API_BASE . '/groups?limit=100&sort=name',
self::request_args( $api_key )
);
if ( is_wp_error( $response ) ) {
return $response;
}
$error = GenerateBlocks_Pro_Form_Http::response_error( $response, 'MailerLite' );
if ( $error ) {
return $error;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( empty( $body['data'] ) ) {
return [];
}
return array_map(
function ( $group ) {
return [
'id' => $group['id'],
'name' => $group['name'],
'count' => $group['active_count'] ?? 0,
];
},
$body['data']
);
}
/**
* Fetch fields from MailerLite.
*
* @param string $api_key The API key.
* @return array|WP_Error
*/
public static function fetch_fields( $api_key ) {
$response = wp_safe_remote_get(
self::API_BASE . '/fields?limit=100&sort=name',
self::request_args( $api_key )
);
if ( is_wp_error( $response ) ) {
return $response;
}
$error = GenerateBlocks_Pro_Form_Http::response_error( $response, 'MailerLite' );
if ( $error ) {
return $error;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( empty( $body['data'] ) || ! is_array( $body['data'] ) ) {
return [];
}
$options = [];
foreach ( $body['data'] as $field ) {
$key = sanitize_text_field( $field['key'] ?? '' );
$name = sanitize_text_field( $field['name'] ?? $key );
if ( '' === $key || 'email' === $key || ! preg_match( '/^[A-Za-z0-9_]{1,64}$/', $key ) ) {
continue;
}
$options[] = [
'value' => $key,
'label' => $name . ' (' . $key . ')',
];
}
return $options;
}
/**
* Test the API connection.
*
* @param string $api_key The API key.
* @return true|WP_Error
*/
public static function test_connection( $api_key ) {
$response = wp_safe_remote_get(
self::API_BASE . '/subscribers?limit=0',
self::request_args( $api_key )
);
if ( is_wp_error( $response ) ) {
return new WP_Error( 'connection_failed', 'Could not connect to MailerLite. Check your API key.' );
}
if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
return new WP_Error( 'auth_failed', 'Invalid MailerLite API key.' );
}
return true;
}
}
@@ -0,0 +1,43 @@
<?php
/**
* Webhook email signup integration.
*
* Sends signup submissions to a configured webhook URL through the
* email-signup action.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Webhook email signup provider.
*/
class GenerateBlocks_Pro_Form_Integration_Webhook {
/**
* Register the provider with the registry.
*/
public static function init() {
generateblocks_pro_register_form_integration(
[
'id' => 'webhook',
'label' => __( 'Webhook', 'generateblocks-pro' ),
'type' => 'email',
'settings_fields' => [
[
'key' => 'url',
'label' => __( 'Webhook URL', 'generateblocks-pro' ),
'type' => 'url',
'placeholder' => 'https://example.com/webhook',
'required' => true,
],
],
'settings_validate_callback' => [ 'GenerateBlocks_Pro_Form_Action_Webhook', 'validate_signup_settings' ],
'subscribe_callback' => [ 'GenerateBlocks_Pro_Form_Action_Webhook', 'subscribe' ],
]
);
}
}