initial
This commit is contained in:
@@ -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>';
|
||||
}
|
||||
+391
@@ -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;
|
||||
}
|
||||
}
|
||||
+397
@@ -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 );
|
||||
}
|
||||
}
|
||||
+445
@@ -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' ],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user