10 ) {
* // Add to blocklist
* update_option( 'blocked_ips', array_merge(
* get_option( 'blocked_ips', [] ),
* [ $ip ]
* ) );
* }
* }, 10, 3 );
* ```
*/
class FormHandler {
/**
* Security module for spam checks, rate limiting, and verification.
*
* @var FormSecurity
*/
private FormSecurity $security;
/**
* Transient holding definitions for forms that live outside wp_posts.
*
* Bump the version whenever the definition shape changes, so a cached
* definition missing a newer key never skips that key's checks.
*/
const EXTERNAL_DEFINITIONS_CACHE = 'airowp_form_external_definitions_v4';
/**
* Transient prefix for a single form definition resolved from wp_posts.
*
* Version 3 adds `rules`, and drops definitions v2 cached from
* password-protected posts; v4 (and EXTERNAL_DEFINITIONS_CACHE v3) add
* `labels`; v5 (and EXTERNAL_DEFINITIONS_CACHE v4) add `conditions`.
* Bump alongside EXTERNAL_DEFINITIONS_CACHE.
*/
const DEFINITION_CACHE_PREFIX = 'airowp_form_definition_v5_';
/**
* Constructor.
*/
public function __construct() {
$this->security = new FormSecurity();
add_action( 'rest_api_init', array( $this, 'register_rest_endpoint' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'localize_form_script' ) );
// Admin-ajax fallback for hosts that rate-limit /wp-json/ (e.g. Cloudflare/GoDaddy).
add_action( 'wp_ajax_airowp_form_submit', array( $this, 'handle_ajax_submission' ) );
add_action( 'wp_ajax_nopriv_airowp_form_submit', array( $this, 'handle_ajax_submission' ) );
// Non-AJAX form POST handler. Forms post to admin-post.php?action=…
// (Form_No_JS_Submit::post_url()) and are caught on admin_init, before
// admin-post.php routes on $_REQUEST['action'], which an author's own
// field named `action` would override. The admin_post hooks still
// serve pages cached with the older hidden `action` field.
add_action( 'admin_init', array( $this, 'maybe_handle_post_submission' ), 0 );
add_action( 'admin_post_airowp_form_submit', array( $this, 'handle_post_submission' ) );
add_action( 'admin_post_nopriv_airowp_form_submit', array( $this, 'handle_post_submission' ) );
// Register cron callback (scheduling handled by activation hook).
add_action( 'airowp_cleanup_old_submissions', array( $this, 'cleanup_old_submissions' ) );
// Invalidate cached form block attributes when posts are saved.
add_action( 'save_post', array( $this, 'clear_form_attributes_cache' ) );
// Forms outside wp_posts (block widgets, theme-file templates, patterns).
add_action( 'update_option_widget_block', array( $this, 'clear_external_form_definitions' ) );
add_action( 'switch_theme', array( $this, 'clear_external_form_definitions' ) );
}
/**
* Get form settings.
*
* @return array Form settings with defaults applied.
*/
private function get_form_settings() {
$settings = get_option( 'airowp_settings', array() );
$defaults = array(
'enable_honeypot' => true,
'enable_rate_limiting' => true,
'retention_days' => 30,
);
return isset( $settings['forms'] ) ? wp_parse_args( $settings['forms'], $defaults ) : $defaults;
}
/**
* Register REST API endpoint for form submission.
*
* SECURITY NOTE: This is a public endpoint (permission_callback = __return_true)
* because it needs to accept form submissions from non-logged-in users.
*
* Security measures in place:
* - Honeypot field check (detects spam bots) - configurable via settings
* - Time-based submission check (< 3 seconds = likely bot)
* - Rate limiting (3 submissions per 60 seconds per IP address) - configurable via settings
* - Comprehensive field validation (email, url, phone, number types)
* - Type-specific sanitization for all field values
* - Server-side email configuration lookup (email settings are never sent from the client)
* - Email header injection prevention (defense in depth)
*
* Additional protection available:
* - Cloudflare Turnstile integration (configurable per-form)
* - More aggressive rate limiting via filters
* - IP blocklist functionality via security monitoring hooks
*
* Extensibility:
* - Use 'airowp_form_rate_limit_count' filter to adjust rate limit
* - Use 'airowp_form_rate_limit_window' filter to adjust time window
* - Use 'airowp_form_submitted' action to hook into successful submissions
*/
public function register_rest_endpoint() {
register_rest_route(
'airo-wp/v1',
'/form/submit',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_form_submission' ),
'permission_callback' => '__return_true', // Public endpoint - see DocBlock above for security measures.
'args' => array(
'formId' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function ( $param ) {
return is_string( $param ) && ! empty( $param );
},
),
'fields' => array(
'required' => true,
'type' => 'array',
'validate_callback' => function ( $param ) {
return is_array( $param );
},
),
'honeypot' => array(
'required' => false,
'type' => 'string',
'default' => '',
),
'timestamp' => array(
'required' => false,
'type' => 'string',
'default' => '',
),
'submittedAt' => array(
'required' => false,
'type' => 'string',
'default' => '',
),
'sourcePostId' => array(
'required' => false,
'type' => 'integer',
'default' => 0,
'sanitize_callback' => 'absint',
),
'turnstile_token' => array(
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function ( $value ) {
if ( ! is_string( $value ) ) {
return new \WP_Error(
'invalid_turnstile_token',
__( 'Invalid Turnstile token format.', 'airo-wp' )
);
}
// Empty is valid (graceful degradation).
if ( '' === $value ) {
return true;
}
// The token is opaque: Cloudflare only guarantees it is at most
// 2048 characters, and its alphabet is not contractual. Real tokens
// are dot-delimited, so do not try to parse their structure —
// bound the length and reject anything that is not printable ASCII
// (whitespace, newlines, NUL) before forwarding it to siteverify.
if ( strlen( $value ) > 2048 || ! preg_match( '/^[!-~]+$/', $value ) ) {
return new \WP_Error(
'invalid_turnstile_token',
__( 'Invalid Turnstile token format.', 'airo-wp' )
);
}
return true;
},
),
),
)
);
}
/**
* Handle form submission.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure.
*/
public function handle_form_submission( $request ) {
// CSRF Protection: Verify nonce when one is supplied.
//
// Rationale: WordPress only emits a valid `wp_rest` nonce for users with
// an active session — fully anonymous visitors hit this endpoint without
// one. We can't require a nonce universally without breaking unauthenticated
// public-facing forms (the primary use case for this block). Instead, we
// layer non-cookie defences for anonymous submissions:
// 1. Honeypot field (rejects most bots).
// 2. Submission-timing check (rejects sub-3s autofill).
// 3. IP rate limiting (default 2/60s, configurable per-form).
// 4. Optional Cloudflare Turnstile (recommended for high-value forms).
// For sensitive use cases, enable Turnstile in the block's Inspector.
$nonce = $request->get_header( 'X-WP-Nonce' );
if ( $nonce && ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
return new WP_Error(
'invalid_nonce',
__( 'Security verification failed. Please refresh the page and try again.', 'airo-wp' ),
array( 'status' => 403 )
);
}
$form_id = $request->get_param( 'formId' );
$fields = $request->get_param( 'fields' );
$honeypot = $request->get_param( 'honeypot' );
$timestamp = $request->get_param( 'timestamp' );
$form_settings = $this->get_form_settings();
// Throttle by IP before anything touches the database: every attempt
// counts, including unknown-form and failed ones, so unique form IDs
// can't force a content scan per request.
if ( $form_settings['enable_rate_limiting'] ) {
$attempt_check = $this->security->reserve_attempt();
if ( is_wp_error( $attempt_check ) ) {
return $attempt_check;
}
}
// These reach string-typed checks below. A client can send any JSON
// type, and an array would be a TypeError (HTTP 500) rather than an
// answer. A non-scalar honeypot is treated as filled (a real browser
// only ever sends a string); a non-scalar timestamp as absent.
if ( ! is_scalar( $form_id ) ) {
return $this->invalid_submission_error();
}
$form_id = (string) $form_id;
$honeypot = is_scalar( $honeypot ) ? (string) $honeypot : '1';
$timestamp = is_scalar( $timestamp ) ? (string) $timestamp : '';
// Reject structurally invalid values before any validator or sanitizer
// sees them: they expect a scalar (or, for multi-choice fields, a flat
// list of scalars), and anything else would be a TypeError. Entries
// without a usable name are skipped further down, so leave those be.
foreach ( (array) $fields as $field ) {
if ( ! is_array( $field ) || ! isset( $field['name'], $field['value'] ) || ! is_scalar( $field['name'] ) ) {
continue;
}
if ( ! $this->is_valid_field_value_shape( $field['value'] ) ) {
return $this->invalid_submission_error();
}
}
// Resolve the complete published form definition before running checks that
// can consume resources. A form ID is an untrusted client value: accepting
// an unknown one would let callers bypass its field schema and per-form
// controls entirely.
$unknown_form_error = new WP_Error(
'unknown_form',
__( 'This form is no longer available.', 'airo-wp' ),
array( 'status' => 404 )
);
// Only a site-wide miss is cached. A form looked up through its source
// post can be hidden from one visitor (a password-protected page) and
// visible to the next, so that miss must not lock the form for others.
$source_post_id = $this->get_source_post_id( $request );
if ( ! $source_post_id && $this->security->is_unknown_form_cached( $form_id ) ) {
return $unknown_form_error;
}
$form_definition = $this->get_form_definition( $form_id, $source_post_id );
if ( null === $form_definition ) {
if ( ! $source_post_id ) {
$this->security->cache_unknown_form( $form_id );
}
return $unknown_form_error;
}
$block_attrs = $form_definition['attributes'];
$rate_limit_count = isset( $block_attrs['rateLimitCount'] ) ? absint( $block_attrs['rateLimitCount'] ) : 3;
$rate_limit_window = isset( $block_attrs['rateLimitWindow'] ) ? absint( $block_attrs['rateLimitWindow'] ) : 60;
$turnstile_required = ! empty( $block_attrs['enableTurnstile'] );
// Honeypot spam check (only if enabled in settings).
if ( $form_settings['enable_honeypot'] ) {
$honeypot_check = $this->security->check_honeypot( $honeypot, $form_id );
if ( is_wp_error( $honeypot_check ) ) {
return $honeypot_check;
}
}
// Time-based spam check.
$submitted_at = $request->get_param( 'submittedAt' );
$submitted_at = is_scalar( $submitted_at ) ? (string) $submitted_at : '';
$timing_check = $this->security->check_submission_timing( $timestamp, $form_id, $submitted_at );
if ( is_wp_error( $timing_check ) ) {
return $timing_check;
}
// Rate limiting check (only if enabled in settings).
// Uses per-block rateLimitCount/rateLimitWindow attributes as defaults for the filter.
if ( $form_settings['enable_rate_limiting'] ) {
$rate_limit_check = $this->security->check_rate_limit( $form_id, $rate_limit_count );
if ( is_wp_error( $rate_limit_check ) ) {
return $rate_limit_check;
}
$global_rate_limit_check = $this->security->check_global_rate_limit();
if ( is_wp_error( $global_rate_limit_check ) ) {
return $global_rate_limit_check;
}
}
// Turnstile verification. Only a form that requires Turnstile checks a
// token: a stray one on any other form is ignored rather than
// verified, so that form never depends on Cloudflare being reachable.
if ( $turnstile_required ) {
// Without both keys the widget can't render, or the server can't
// check what it returns. Say so plainly instead of asking the
// visitor to complete a challenge that isn't there.
if ( ! FormSecurity::is_turnstile_configured() ) {
/** This action is documented below. */
do_action( 'airowp_form_turnstile_failed', $form_id, $this->security->get_client_ip(), 'turnstile_not_configured' );
return new WP_Error(
'turnstile_not_configured',
__( 'This form can\'t accept submissions right now. Please contact the site owner.', 'airo-wp' ),
array( 'status' => 503 )
);
}
$turnstile_token = (string) $request->get_param( 'turnstile_token' );
if ( '' === $turnstile_token ) {
return new WP_Error(
'turnstile_required',
__( 'Security verification is required. Please complete the challenge and try again.', 'airo-wp' ),
array( 'status' => 403 )
);
}
$turnstile_result = $this->security->verify_turnstile( $turnstile_token );
if ( is_wp_error( $turnstile_result ) ) {
/**
* Fires when Cloudflare Turnstile verification fails.
*
* @since 1.0.0
* @param string $form_id Form identifier.
* @param string $ip_address Client IP address.
* @param string $error_code Error code from verification.
*/
do_action( 'airowp_form_turnstile_failed', $form_id, $this->security->get_client_ip(), $turnstile_result->get_error_code() );
return $turnstile_result;
}
}
// Conditional logic: fields the visitor couldn't see neither block the
// submission nor carry values (a hidden field's value is dropped even if
// a client sends one). Rules read text-like values as the loop below
// will sanitize and store them, so markup or %xx octets can't make a
// rule see one answer while another is stored. Blank values and the
// number/url/email/tel types stay raw: their sanitizers rewrite blanks
// and formats ('' → 0, example.com → http://example.com), which would
// diverge from the browser's evaluation, and validate_field() already
// rejects malformed values of those types.
list( $fields, $form_definition['required_fields'] ) = Form_Conditions_Submission::filter_submission(
$form_definition,
(array) $fields,
function ( $value, $type ) {
if ( '' === trim( (string) $value ) || in_array( $type, array( 'number', 'url', 'email', 'tel' ), true ) ) {
return $value;
}
return $this->sanitize_field( $value, $type );
}
);
// Sanitize and validate all fields.
$form_field_types = $form_definition['field_types'];
$field_constraints = $form_definition['constraints'];
$sanitized_fields = array();
foreach ( $fields as $field ) {
if ( ! isset( $field['name'] ) || ! isset( $field['value'] ) ) {
continue;
}
$field_name = sanitize_text_field( $field['name'] );
$field_value = $field['value'];
$submitted_field_type = isset( $field['type'] ) ? sanitize_text_field( $field['type'] ) : 'text';
// Only fields the saved form declares are validated, stored and
// emailed. Anything else is dropped rather than failing the whole
// submission: Cloudflare Turnstile injects cf-turnstile-response
// inside the form, other plugins add hidden inputs of their own, and
// none of that is the visitor's data.
if ( ! isset( $form_field_types[ $field_name ] ) && ! empty( $form_field_types ) ) {
continue;
}
$field_type = isset( $form_field_types[ $field_name ] ) ? $form_field_types[ $field_name ] : $submitted_field_type;
// Server-defined allowed values for constrained field types (only
// present for select/checkbox/hidden fields resolved from the block).
$allowed_values = isset( $field_constraints[ $field_name ] )
? $field_constraints[ $field_name ]
: null;
// Type-specific validation, then the constraints the field renders
// (minlength, pattern, min/max, step), which only the browser
// enforced before.
$validation_result = $this->validate_field( $field_value, $field_type, $allowed_values );
if ( true === $validation_result && isset( $form_definition['rules'][ $field_name ] ) ) {
$validation_result = Form_Field_Rules::check( $field_value, $form_definition['rules'][ $field_name ] );
}
if ( is_wp_error( $validation_result ) ) {
// Security monitoring hook for validation failures.
do_action( 'airowp_form_validation_failed', $form_id, $field_name, $field_type, $validation_result->get_error_code(), $this->security->get_client_ip() );
return new WP_Error(
'validation_error',
sprintf(
/* translators: %1$s: field name, %2$s: error message */
__( 'Field "%1$s": %2$s', 'airo-wp' ),
$field_name,
$validation_result->get_error_message()
),
array(
'status' => 400,
'field' => $field_name,
)
);
}
// Type-specific sanitization.
$sanitized_value = $this->sanitize_field( $field_value, $field_type );
$sanitized_fields[ $field_name ] = array(
'value' => $sanitized_value,
'type' => $field_type,
);
}
foreach ( $form_definition['required_fields'] as $field_name ) {
if ( ! isset( $sanitized_fields[ $field_name ] ) || $this->is_empty_field_value( $sanitized_fields[ $field_name ]['value'] ) ) {
return new WP_Error(
'required_field_missing',
__( 'Please complete all required fields.', 'airo-wp' ),
array(
'status' => 400,
'field' => $field_name,
)
);
}
}
// Reserve the rate-limit slot before the write, not after it: parallel
// requests would otherwise all pass the earlier check and all store.
if ( $form_settings['enable_rate_limiting'] ) {
$rate_limit_check = $this->security->check_rate_limit( $form_id, $rate_limit_count );
if ( is_wp_error( $rate_limit_check ) ) {
return $rate_limit_check;
}
$global_rate_limit_check = $this->security->check_global_rate_limit();
if ( is_wp_error( $global_rate_limit_check ) ) {
return $global_rate_limit_check;
}
$this->security->increment_rate_limit( $form_id, $rate_limit_window );
$this->security->increment_global_rate_limit();
}
// Store submission.
$submission_id = $this->store_submission(
$form_id,
$sanitized_fields,
isset( $form_definition['labels'] ) ? $form_definition['labels'] : array()
);
if ( is_wp_error( $submission_id ) ) {
return new WP_Error(
'submission_failed',
__( 'Failed to save form submission. Please try again.', 'airo-wp' ),
array( 'status' => 500 )
);
}
// Send email notification if enabled (settings looked up server-side from block attributes).
$this->send_email_notification( $form_id, $sanitized_fields, $submission_id, $block_attrs );
// Trigger action hook for email notifications, integrations, etc.
// The fourth argument is the form's server-resolved block attributes.
do_action( 'airowp_form_submitted', $submission_id, $form_id, $sanitized_fields, $block_attrs );
return new WP_REST_Response(
array(
'success' => true,
'message' => __( 'Form submitted successfully!', 'airo-wp' ),
'submissionId' => $submission_id,
),
200
);
}
/**
* Whether a submitted value is a scalar or a flat list of scalars.
*
* @param mixed $value Raw submitted value.
* @return bool
*/
private function is_valid_field_value_shape( $value ) {
if ( is_scalar( $value ) ) {
return true;
}
if ( ! is_array( $value ) ) {
return false;
}
foreach ( $value as $item ) {
if ( ! is_scalar( $item ) ) {
return false;
}
}
return true;
}
/**
* Error for a request whose structure is not a valid form submission.
*
* @return WP_Error
*/
private function invalid_submission_error() {
return new WP_Error(
'invalid_submission',
__( 'Invalid form submission.', 'airo-wp' ),
array( 'status' => 400 )
);
}
/**
* Determine whether a sanitized field value should fail a required check.
*
* @param mixed $value Sanitized field value.
* @return bool True when the value is empty.
*/
private function is_empty_field_value( $value ) {
if ( is_array( $value ) ) {
return empty( $value );
}
return '' === trim( (string) $value );
}
/**
* Handle form submission via admin-ajax.php (fallback for rate-limited REST API).
*
* Wraps the REST handler by building a WP_REST_Request from $_POST data.
*/
public function handle_ajax_submission() {
// Verify nonce.
if ( ! check_ajax_referer( 'airowp_form_submit', '_ajax_nonce', false ) ) {
wp_send_json_error(
array( 'message' => __( 'Security verification failed. Please refresh the page and try again.', 'airo-wp' ) ),
403
);
}
$request = new \WP_REST_Request( 'POST' );
$request->set_header( 'Content-Type', 'application/json' );
// Read JSON form data from the form_data POST field (form-encoded).
// Form-encoded is used because some hosts (GoDaddy/Cloudflare) block
// application/json POST requests with 429 errors.
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized by handle_form_submission.
$raw_data = isset( $_POST['form_data'] ) ? wp_unslash( $_POST['form_data'] ) : '';
$data = json_decode( $raw_data, true );
if ( ! is_array( $data ) ) {
wp_send_json_error(
array( 'message' => __( 'Invalid request data.', 'airo-wp' ) ),
400
);
}
$request->set_param( 'formId', isset( $data['formId'] ) ? sanitize_text_field( $data['formId'] ) : '' );
$request->set_param( 'fields', isset( $data['fields'] ) ? $data['fields'] : array() );
$request->set_param( 'honeypot', isset( $data['honeypot'] ) ? $data['honeypot'] : '' );
$request->set_param( 'timestamp', isset( $data['timestamp'] ) ? $data['timestamp'] : '' );
$request->set_param( 'submittedAt', isset( $data['submittedAt'] ) ? $data['submittedAt'] : '' );
$request->set_param( 'sourcePostId', isset( $data['sourcePostId'] ) ? absint( $data['sourcePostId'] ) : 0 );
$request->set_param( 'turnstile_token', isset( $data['turnstile_token'] ) ? sanitize_text_field( $data['turnstile_token'] ) : '' );
$result = $this->handle_form_submission( $request );
if ( is_wp_error( $result ) ) {
$status = $result->get_error_data() && isset( $result->get_error_data()['status'] )
? $result->get_error_data()['status']
: 400;
wp_send_json_error(
array( 'message' => $result->get_error_message() ),
$status
);
}
wp_send_json_success( $result->get_data() );
}
/**
* Handle a form posted to Form_No_JS_Submit::post_url().
*
* Runs on admin_init, which admin-post.php fires before it routes on
* $_REQUEST['action']. WordPress builds that from $_GET and $_POST with
* POST winning, so an author's field named `action` would otherwise send
* the request to an action nobody handles.
*/
public function maybe_handle_post_submission() {
global $pagenow;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Routing only; handle_post_submission() verifies the nonce.
$action = isset( $_GET['action'] ) && is_string( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : '';
$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
if ( 'admin-post.php' !== $pagenow || 'POST' !== $method || Form_No_JS_Submit::ACTION !== $action ) {
return;
}
$this->handle_post_submission();
}
/**
* Handle non-AJAX form submission via admin_post.
*
* Processes standard form POST and redirects back with a status query param.
*/
public function handle_post_submission() {
// CSRF: a logged-in visitor must send a valid nonce, so no other site
// can submit a form in their name.
//
// A logged-out visitor's nonce protects nothing: it is the same for
// every anonymous visitor and printed in public page HTML, and the
// REST endpoint already takes anonymous submissions with no nonce at
// all. It is also the one that goes stale, because full-page caches
// serve anonymous visitors markup that can be older than a nonce's
// ~24h life. Requiring it turned every submission from such a page
// into a dead end. Anonymous submissions still pass the honeypot,
// timing, rate-limit and Turnstile checks below.
$nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';
if ( is_user_logged_in() && ! wp_verify_nonce( $nonce, Form_No_JS_Submit::ACTION ) ) {
wp_die(
esc_html__( 'Security verification failed. Please go back, reload the page and try again.', 'airo-wp' ),
'',
array(
'response' => 403,
'back_link' => true,
)
);
}
$referer = wp_get_referer();
if ( ! $referer ) {
wp_die( esc_html__( 'Invalid form submission.', 'airo-wp' ), '', array( 'response' => 400 ) );
}
// Build fields array from POST data.
$form_id = isset( $_POST['dsg_form_id'] ) ? sanitize_text_field( wp_unslash( $_POST['dsg_form_id'] ) ) : '';
$fields = array();
// Read field type map (JSON mapping of field name => type).
$field_types = array();
if ( isset( $_POST['dsg_field_types'] ) ) {
$decoded = json_decode( sanitize_text_field( wp_unslash( $_POST['dsg_field_types'] ) ), true );
if ( is_array( $decoded ) ) {
$field_types = $decoded;
}
}
$system_fields = array( 'dsg_website', 'dsg_form_id', 'dsg_timestamp', 'dsg_submitted_at', 'dsg_turnstile_token', 'dsg_field_types', '_wpnonce' );
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above.
foreach ( $_POST as $key => $value ) {
// Skip system fields. `action` is ours only when it carries our
// value (a page cached before the action moved into the URL);
// otherwise it is an author's field.
if ( in_array( $key, $system_fields, true ) || ( 'action' === $key && Form_No_JS_Submit::ACTION === $value ) ) {
continue;
}
$field_type = isset( $field_types[ $key ] ) ? sanitize_text_field( $field_types[ $key ] ) : 'text';
// String values go through unsanitized, as they do on the REST
// path: handle_form_submission() checks each against the field's
// constraints and then sanitizes it by field type. Sanitizing here
// first changed what was checked (`<` grew into `<`, whitespace
// collapsed), rejecting values the browser had accepted, and
// stripped a textarea's line breaks before sanitize_textarea_field()
// could keep them. An array (`name[]`) becomes an empty string, as
// sanitize_text_field() always made it.
$fields[] = array(
'name' => $key,
'value' => is_string( $value ) ? wp_unslash( $value ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated and sanitized by type in handle_form_submission().
'type' => $field_type,
);
}
$request = new \WP_REST_Request( 'POST' );
$request->set_param( 'formId', $form_id );
$request->set_param( 'fields', $fields );
$request->set_param( 'honeypot', isset( $_POST['dsg_website'] ) ? sanitize_text_field( wp_unslash( $_POST['dsg_website'] ) ) : '' );
$request->set_param( 'timestamp', isset( $_POST['dsg_timestamp'] ) ? sanitize_text_field( wp_unslash( $_POST['dsg_timestamp'] ) ) : '' );
// Posted only by the script's native-POST fallback, on the same browser
// clock as its dsg_timestamp. A visitor without JavaScript has neither,
// and the server-rendered timestamp is measured against server time.
$request->set_param( 'submittedAt', isset( $_POST['dsg_submitted_at'] ) ? sanitize_text_field( wp_unslash( $_POST['dsg_submitted_at'] ) ) : '' );
$request->set_param( 'turnstile_token', isset( $_POST['dsg_turnstile_token'] ) ? sanitize_text_field( wp_unslash( $_POST['dsg_turnstile_token'] ) ) : '' );
$result = $this->handle_form_submission( $request );
$redirect = is_wp_error( $result ) ? '' : $this->get_success_redirect_url( $form_id, $request );
if ( is_wp_error( $result ) ) {
// Without JavaScript the visitor comes back to a fresh page: keep
// their answers, and say why it failed (Form_No_JS_Retry).
Form_No_JS_Retry::remember( $form_id, $fields, (string) $result->get_error_code() );
}
if ( '' === $redirect ) {
$redirect = add_query_arg(
array_merge(
array(
'airowp_form_status' => is_wp_error( $result ) ? 'error' : 'success',
'airowp_form_id' => $form_id,
),
is_wp_error( $result ) ? Form_No_JS_Retry::redirect_args( $result ) : array()
),
// A retry is posted from the previous result's URL: drop its
// reason, or a later result would name the earlier field.
remove_query_arg( array( 'airowp_form_error', 'airowp_form_field' ), $referer )
);
// Land on the result Form_No_JS_Submit prints, not the page top.
$anchor = Form_No_JS_Submit::message_anchor( $form_id );
if ( '' !== $anchor && false === strpos( $redirect, '#' ) ) {
$redirect .= '#' . $anchor;
}
}
wp_safe_redirect( $redirect );
exit;
}
/**
* The form's own "Redirect URL", for a submission that did not go through AJAX.
*
* The AJAX path follows this URL in view.js; a plain POST — AJAX Submit
* off, or the automatic fallback when a host blocks both REST and
* admin-ajax — must land in the same place. The URL comes from the
* published form's attributes, never from the request, and matches
* view.js's rule: http(s) only, any host.
*
* @param string $form_id Form ID.
* @param WP_REST_Request $request Submission request, for the source post.
* @return string Redirect URL, or '' when the form has none.
*/
private function get_success_redirect_url( $form_id, $request ) {
$definition = $this->get_form_definition( $form_id, $this->get_source_post_id( $request ) );
$url = isset( $definition['attributes']['redirectUrl'] ) ? $definition['attributes']['redirectUrl'] : '';
$url = is_string( $url ) ? esc_url_raw( trim( $url ), array( 'http', 'https' ) ) : '';
if ( '' === $url ) {
return '';
}
// wp_safe_redirect() only follows hosts on this list, so admit the one
// the author configured.
$host = wp_parse_url( $url, PHP_URL_HOST );
if ( $host ) {
add_filter(
'allowed_redirect_hosts',
static function ( $hosts ) use ( $host ) {
$hosts[] = $host;
return $hosts;
}
);
}
return $url;
}
/**
* Validate field based on type.
*
* @param mixed $value Field value.
* @param string $type Field type.
* @param array|null $allowed Optional list of server-defined allowed values
* (for select/checkbox/hidden). When provided, the
* submitted value(s) must be members of this list.
* @return true|WP_Error True if valid, WP_Error if invalid.
*/
private function validate_field( $value, $type, $allowed = null ) {
// Single-value types are checked by functions that need a string.
if ( is_array( $value ) && in_array( $type, array( 'email', 'url', 'number', 'tel', 'country_code' ), true ) ) {
return new WP_Error(
'invalid_value',
__( 'Invalid value.', 'airo-wp' )
);
}
// Skip validation for empty values (optional fields).
// Required fields are validated by HTML5 on the frontend.
if ( empty( $value ) && '0' !== $value ) {
return true;
}
// Enforce server-defined allowed values for constrained field types
// (select options, checkbox value, hidden constant). This prevents a
// client from submitting arbitrary values or forging hidden fields —
// the field type itself is already resolved server-side.
if ( is_array( $allowed ) ) {
$submitted = is_array( $value ) ? $value : array( $value );
foreach ( $submitted as $single ) {
if ( ! in_array( (string) $single, $allowed, true ) ) {
return new WP_Error(
'value_not_allowed',
__( 'Submitted value is not an allowed option.', 'airo-wp' )
);
}
}
}
switch ( $type ) {
case 'email':
if ( ! is_email( $value ) ) {
return new WP_Error(
'invalid_email',
__( 'Invalid email address.', 'airo-wp' )
);
}
break;
case 'url':
if ( ! filter_var( $value, FILTER_VALIDATE_URL ) || ! preg_match( '/^https?:\/\//i', $value ) ) {
return new WP_Error(
'invalid_url',
__( 'Invalid URL.', 'airo-wp' )
);
}
break;
case 'number':
if ( ! is_numeric( $value ) ) {
return new WP_Error(
'invalid_number',
__( 'Invalid number.', 'airo-wp' )
);
}
break;
case 'tel':
// Basic phone validation - numbers, spaces, dashes, dots, parentheses, plus.
if ( ! preg_match( '/^[0-9\s\-\.\(\)\+]+$/', $value ) ) {
return new WP_Error(
'invalid_phone',
__( 'Invalid phone number.', 'airo-wp' )
);
}
break;
case 'country_code':
// Every entry in form-phone-field/country-codes.js is "+" and 1-4 digits.
if ( ! is_string( $value ) || ! preg_match( '/^\+\d{1,4}$/', $value ) ) {
return new WP_Error(
'invalid_country_code',
__( 'Invalid country code.', 'airo-wp' )
);
}
break;
}
return true;
}
/**
* Sanitize field based on type.
*
* @param mixed $value Field value.
* @param string $type Field type.
* @return mixed Sanitized value.
*/
private function sanitize_field( $value, $type ) {
switch ( $type ) {
case 'email':
return sanitize_email( $value );
case 'url':
return esc_url_raw( $value );
case 'number':
// A blank stays blank so an optional number is stored empty and a
// required one still fails the empty check. validate_field() has
// already rejected non-numeric input, so '' here is never a
// silent coercion of bad data.
return ( is_scalar( $value ) && is_numeric( $value ) ) ? floatval( $value ) : '';
case 'tel':
return preg_replace( '/[^0-9\s\-\.\(\)\+]/', '', $value );
case 'textarea':
return sanitize_textarea_field( $value );
case 'text':
default:
return sanitize_text_field( $value );
}
}
/**
* Store form submission as custom post type.
*
* @param string $form_id Form ID.
* @param array $fields Sanitized fields array.
* @param array $labels Field labels keyed by name.
* @return int|WP_Error Post ID on success, WP_Error on failure.
*/
private function store_submission( $form_id, $fields, array $labels = array() ) {
foreach ( $fields as $name => $data ) {
if ( isset( $labels[ $name ] ) ) {
$fields[ $name ]['label'] = $labels[ $name ];
}
}
$post_id = wp_insert_post(
array(
'post_type' => 'dsgo_form_submission',
'post_status' => 'private',
'post_title' => sprintf(
/* translators: %s: form ID */
__( 'Form Submission - %s', 'airo-wp' ),
$form_id
),
'post_date' => current_time( 'mysql' ),
),
true
);
if ( is_wp_error( $post_id ) ) {
return $post_id;
}
// Store form data as post meta.
//
// update_metadata() runs wp_unslash() on every value it stores, so it expects
// SLASHED input. Everything below has already been unslashed (REST params
// arrive unslashed; the no-JS $_POST path unslashes explicitly), so passing
// these straight through would strip one level of escaping and silently eat
// backslashes out of submitted content — "C:\Users\me" would land as
// "C:Usersme". Re-slash the user-controlled values to survive that unslash.
// wp_slash() walks arrays, so $fields is covered in full.
update_post_meta( $post_id, '_dsg_form_id', $form_id );
update_post_meta( $post_id, '_dsg_form_fields', wp_slash( $fields ) );
update_post_meta( $post_id, '_dsg_submission_ip', $this->security->get_client_ip() );
update_post_meta( $post_id, '_dsg_submission_user_agent', wp_slash( $this->get_user_agent() ) );
update_post_meta( $post_id, '_dsg_submission_referer', wp_slash( $this->get_submission_source_url() ) );
update_post_meta( $post_id, '_dsg_submission_date', current_time( 'mysql' ) );
// Clear cached form submission count.
delete_transient( 'dsgo_form_submissions_count' );
return $post_id;
}
/**
* Get user agent string.
*
* @return string User agent.
*/
private function get_user_agent() {
return isset( $_SERVER['HTTP_USER_AGENT'] )
? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) )
: 'unknown';
}
/**
* Localize script with nonce and REST URL.
*/
public function localize_form_script() {
// Deliberately unconditional. This used to be guarded by
// has_block( 'airo-wp/form-builder' ), which only inspects the
// current post's content — so a form living in a template part
// (a newsletter signup in the footer), a synced pattern, a block
// widget, or anything rendered via do_blocks() never matched. The
// block still rendered and WordPress still enqueued its viewScript,
// so view.js ran with airowpForm undefined and every submission
// died on a ReferenceError.
//
// No guard is needed: wp_localize_script() only attaches data to a
// registered handle. WordPress enqueues the block's viewScript at
// render time, and the data is printed only if that happens — on a
// page with no form, this outputs nothing.
$handle = 'airo-wp-form-builder-view-script';
// Localize with nonce and REST URL.
wp_localize_script(
$handle,
'airowpForm',
array(
'nonce' => wp_create_nonce( 'wp_rest' ),
'restUrl' => rest_url( 'airo-wp/v1/form/submit' ),
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'adminPostUrl' => Form_No_JS_Submit::post_url(),
'ajaxNonce' => wp_create_nonce( 'airowp_form_submit' ),
// Sent back as sourcePostId so a form ID shared by several pages
// resolves to the copy on this one.
'postId' => is_singular() ? get_queried_object_id() : 0,
)
);
// Localize integrations settings for Turnstile.
$settings = get_option( 'airowp_settings', array() );
$integrations_settings = isset( $settings['integrations'] ) ? $settings['integrations'] : array();
wp_localize_script(
$handle,
'dsgoIntegrations',
array(
'turnstileSiteKey' => ! empty( $integrations_settings['turnstile_site_key'] ) ? esc_js( $integrations_settings['turnstile_site_key'] ) : '',
// wp_localize_script() stringifies booleans ("1" / ""), so read
// it as truthy in JS, never with `=== true`.
'turnstileConfigured' => FormSecurity::is_turnstile_configured(),
)
);
}
/**
* Flatten a submitted field to a string.
*
* A field arrives either as a bare scalar or as `array( 'value' => … )`, and
* that value is itself an array for a multi-value field (checkbox group,
* multi-select) — which esc_html() would raise on.
*
* The whole point of this helper is that EVERY shape is safe to stringify, so
* it must not assume the shape it is handed. A submission is attacker-shaped
* data: an array with no `value` key, or one nesting arrays inside arrays,
* would make a plain strval() emit an "Array to string conversion" warning.
* Flattening recurses instead.
*
* @param mixed $field_data Raw submitted field.
* @return string Flattened value.
*/
private static function stringify_field_value( $field_data ) {
$value = ( is_array( $field_data ) && array_key_exists( 'value', $field_data ) )
? $field_data['value']
: $field_data;
return self::flatten_value( $value );
}
/**
* Recursively reduce any value to a display string.
*
* @param mixed $value Value of arbitrary shape.
* @return string Flattened value.
*/
private static function flatten_value( $value ) {
if ( is_array( $value ) ) {
return implode( ', ', array_map( array( self::class, 'flatten_value' ), $value ) );
}
if ( is_bool( $value ) ) {
return $value ? '1' : '';
}
if ( null === $value || is_scalar( $value ) ) {
return (string) $value;
}
// Objects/resources have no meaningful representation in an email.
return '';
}
/**
* Send email notification with form submission data.
*
* Email configuration is read from the server-side block attributes (stored
* in post content), NOT from the client request. This prevents attackers from
* manipulating email recipients, sender addresses, or body content.
*
* @param string $form_id Form ID.
* @param array $fields Sanitized form fields.
* @param int $submission_id Submission post ID.
* @param array|null $block_attrs Attributes of the form the submission was
* validated against. Passing them keeps the
* email on the same copy of a shared form ID.
*/
private function send_email_notification( $form_id, $fields, $submission_id, $block_attrs = null ) {
// Look up email settings from the form block attributes (server-side only).
// This prevents client-side manipulation of email configuration.
if ( null === $block_attrs ) {
$block_attrs = $this->get_form_block_attributes( $form_id );
}
if ( ! $block_attrs || empty( $block_attrs['enableEmail'] ) ) {
if ( ! $block_attrs && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional debug logging.
sprintf( 'airo-wp Form: Could not find block attributes for form ID "%s".', $form_id )
);
}
return;
}
// Extract raw values from block attributes.
$email_to = isset( $block_attrs['emailTo'] ) ? $block_attrs['emailTo'] : '';
$email_subject = isset( $block_attrs['emailSubject'] ) ? $block_attrs['emailSubject'] : '';
$email_from_name = isset( $block_attrs['emailFromName'] ) ? $block_attrs['emailFromName'] : '';
$email_from = isset( $block_attrs['emailFromEmail'] ) ? $block_attrs['emailFromEmail'] : '';
$email_body = isset( $block_attrs['emailBody'] ) ? $block_attrs['emailBody'] : '';
// emailReplyTo is a field NAME reference (e.g., "email"), not an email address.
// The actual email value is extracted from submitted fields and sanitized below (line ~650).
$email_reply_to = isset( $block_attrs['emailReplyTo'] ) ? $block_attrs['emailReplyTo'] : '';
// Strip newlines from header-used values to prevent injection (defense in depth).
$newline_chars = array( "\r", "\n", '%0a', '%0d' );
$email_to = str_replace( $newline_chars, '', $email_to );
$email_subject = str_replace( $newline_chars, '', $email_subject );
$email_from_name = str_replace( $newline_chars, '', $email_from_name );
$email_from = str_replace( $newline_chars, '', $email_from );
$email_reply_to = str_replace( $newline_chars, '', $email_reply_to );
// Validate and set defaults (single sanitization pass).
// Several recipients may be listed, separated by commas or semicolons.
// Invalid entries are dropped rather than sinking the whole list; only
// a list with no valid address at all falls back to the admin email.
// Kept as a comma-separated string, which wp_mail() accepts and which
// the airowp_form_email_sent hook and stored meta already carry.
$recipients = array_filter(
array_map( 'trim', preg_split( '/[,;]/', $email_to ) ),
'is_email'
);
$email_to = empty( $recipients )
? get_option( 'admin_email' )
: implode( ', ', array_unique( array_map( 'sanitize_email', $recipients ) ) );
if ( empty( $email_subject ) ) {
$email_subject = __( 'New Form Submission', 'airo-wp' );
} else {
$email_subject = sanitize_text_field( $email_subject );
}
if ( empty( $email_from_name ) ) {
$email_from_name = get_bloginfo( 'name' );
} else {
$email_from_name = sanitize_text_field( $email_from_name );
}
if ( empty( $email_from ) ) {
// Use domain-matched email address for better deliverability.
// This matches WordPress core and other plugins like CoBlocks.
$sitename = wp_parse_url( network_home_url(), PHP_URL_HOST );
if ( null !== $sitename ) {
// Remove www prefix if present.
if ( 'www.' === substr( $sitename, 0, 4 ) ) {
$sitename = substr( $sitename, 4 );
}
$email_from = 'wordpress@' . $sitename;
} else {
// Fallback to admin email if we can't parse the domain.
$email_from = get_option( 'admin_email' );
}
} else {
$email_from = sanitize_email( $email_from );
if ( ! is_email( $email_from ) ) {
// Use domain-matched email address as fallback.
$sitename = wp_parse_url( network_home_url(), PHP_URL_HOST );
if ( null !== $sitename ) {
if ( 'www.' === substr( $sitename, 0, 4 ) ) {
$sitename = substr( $sitename, 4 );
}
$email_from = 'wordpress@' . $sitename;
} else {
$email_from = get_option( 'admin_email' );
}
}
}
// Prepare merge tags.
// The page the form was on, not the submit endpoint this request hit
// (REST, admin-ajax and admin-post all carry the page as the referer).
$current_url = esc_url_raw( $this->get_submission_source_url() );
/*
* Merge tags land in TWO places with different rules, so they need two
* maps. Sharing one is what made the first cut of this fix wrong.
*
* BODY is HTML (`Content-Type: text/html`), so values MUST be esc_html()'d
* or a submitter puts arbitrary markup in the owner's inbox.
*
* SUBJECT is a plain-text mail header. It is never parsed as markup, so
* escaping it is not protection, it is corruption: a site named
* "Bob's Bakery & Sons" would arrive as "Bob's Bakery & Sons".
* What the subject actually needs is newline stripping, or a submitted
* value could inject a second mail header.
*
* The subject is a documented merge-tag target ("Use {field_name} for
* dynamic values" in the Form Builder inspector), so this is a real
* authoring surface, not a theoretical one.
*/
// WordPress stores `blogname` ALREADY HTML-escaped — sanitize_option() runs
// esc_html() on save — so get_bloginfo('name') hands back
// `Bob's Bakery & Sons`. Decoding it here is what makes these
// genuinely raw values, and is the same thing core does when it puts the
// site name into an email (see wp_new_user_notification()). The body map
// re-escapes it below; the subject, being plain text, wants it as typed.
$raw_values = array(
'{form_id}' => (string) $form_id,
'{submission_id}' => (string) $submission_id,
'{page_url}' => (string) $current_url,
'{site_name}' => wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ),
'{date}' => (string) current_time( 'mysql' ),
);
foreach ( $fields as $field_name => $field_data ) {
$raw_values[ '{' . $field_name . '}' ] = self::stringify_field_value( $field_data );
}
// The {all_fields} summary is markup by construction, so it is built once
// per context rather than escaped generically: HTML for the body, and a
// flat one-liner for the subject (where tags would be nonsense).
$all_fields_html = '';
$all_fields_text = array();
foreach ( $fields as $field_name => $field_data ) {
$value = self::stringify_field_value( $field_data );
$label = ucwords( str_replace( array( '_', '-' ), ' ', $field_name ) );
$all_fields_html .= sprintf( "%s: %s \n", esc_html( $label ), esc_html( $value ) );
$all_fields_text[] = $label . ': ' . $value;
}
// Body: escape every value. {all_fields} is already escaped internally.
$body_tags = array_map( 'esc_html', $raw_values );
$body_tags['{all_fields}'] = $all_fields_html;
// Subject: do NOT escape. Strip newlines so a submitted value cannot
// smuggle in an extra mail header (PHPMailer would also catch this, but
// the intent belongs here rather than depending on a downstream library).
// The `%0a`/`%0d` literals mirror the header-injection strip at the top of
// this method: a value can arrive percent-encoded from a source that was
// not URL-decoded, and stripping both forms is cheaper than proving no
// such path exists. The trade-off — a subject legitimately containing the
// text "%0a" loses it — is negligible against a header-injection strip.
$newlines = array( "\r", "\n", '%0a', '%0d' );
$subject_tags = array_map(
static function ( $value ) use ( $newlines ) {
return str_replace( $newlines, ' ', $value );
},
$raw_values
);
$subject_tags['{all_fields}'] = str_replace( $newlines, ' ', implode( '; ', $all_fields_text ) );
// Default email body if empty.
if ( empty( $email_body ) ) {
$email_body = __( "New form submission:\n\n{all_fields}\n\nSubmitted from: {page_url}", 'airo-wp' );
} else {
$email_body = sanitize_textarea_field( $email_body );
}
// Replace merge tags — each context with its own map (see above).
$email_subject = str_replace( array_keys( $subject_tags ), array_values( $subject_tags ), $email_subject );
$email_body = str_replace( array_keys( $body_tags ), array_values( $body_tags ), $email_body );
// Convert line breaks to for HTML email.
$email_body = nl2br( $email_body );
// Set up headers.
$headers = array(
'Content-Type: text/html; charset=UTF-8',
sprintf( 'From: %s <%s>', $email_from_name, $email_from ),
);
// Add Reply-To if specified and field exists.
if ( ! empty( $email_reply_to ) && isset( $fields[ $email_reply_to ] ) ) {
// Flatten through the shared helper: a multi-value field yields an
// array, and handing that to str_replace()/sanitize_email() below is a
// PHP 8 TypeError (strlen() on an array) that would take down the whole
// notification. self::stringify_field_value() guarantees a string.
$reply_to_value = self::stringify_field_value( $fields[ $email_reply_to ] );
// Strip newlines to prevent email header injection.
$reply_to_value = str_replace( array( "\r", "\n", '%0a', '%0d' ), '', $reply_to_value );
$reply_to_value = sanitize_email( $reply_to_value );
if ( is_email( $reply_to_value ) ) {
$headers[] = sprintf( 'Reply-To: %s', $reply_to_value );
}
}
// Send email.
$email_sent = wp_mail( $email_to, $email_subject, $email_body, $headers );
// Store email delivery status in submission meta.
update_post_meta( $submission_id, '_dsg_email_sent', $email_sent ? 'yes' : 'no' );
update_post_meta( $submission_id, '_dsg_email_to', $email_to );
update_post_meta( $submission_id, '_dsg_email_sent_date', current_time( 'mysql' ) );
// Log delivery status only; recipients and subjects may contain personal data.
$form_settings = $this->get_form_settings();
$enable_email_logging = isset( $form_settings['enable_email_logging'] ) ? $form_settings['enable_email_logging'] : false;
if ( $enable_email_logging ) {
error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional logging when enabled in settings.
sprintf(
'airo-wp Form: Email %s for submission #%d',
$email_sent ? 'sent successfully' : 'FAILED to send',
$submission_id
)
);
}
// Fire action hook for email monitoring/integration.
do_action( 'airowp_form_email_sent', $submission_id, $form_id, $email_sent, $email_to, $email_subject );
}
/**
* Clean up old form submissions based on retention settings.
*
* Called daily by cron job to delete submissions older than the configured retention period.
* Respects the retention_days setting (default: 30 days).
*
* Processes in batches to prevent timeout issues on sites with large numbers of submissions.
* Use 'airowp_cleanup_batch_size' filter to adjust batch size (default: 100).
*
* @since 1.2.0
*/
public function cleanup_old_submissions() {
$form_settings = $this->get_form_settings();
$retention_days = absint( $form_settings['retention_days'] );
// If retention is 0, keep submissions indefinitely (disable cleanup).
if ( 0 === $retention_days ) {
return;
}
// Calculate cutoff date.
$cutoff_date = gmdate( 'Y-m-d H:i:s', strtotime( "-{$retention_days} days" ) );
// Batch size to prevent timeout (filterable).
$batch_size = apply_filters( 'airowp_cleanup_batch_size', 100 );
// Find old submissions (limited batch to prevent timeout).
$query = new WP_Query(
array(
'post_type' => 'dsgo_form_submission',
'post_status' => 'any',
'date_query' => array(
array(
'before' => $cutoff_date,
),
),
'posts_per_page' => $batch_size,
'fields' => 'ids',
'no_found_rows' => true,
)
);
$old_submissions = $query->posts;
if ( empty( $old_submissions ) ) {
return;
}
// Delete submissions and their metadata.
foreach ( $old_submissions as $submission_id ) {
wp_delete_post( $submission_id, true ); // Force delete (bypass trash).
}
// Clear form submissions count cache.
delete_transient( 'dsgo_form_submissions_count' );
// Log cleanup for debugging.
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional debug logging.
sprintf(
'airo-wp: Deleted %d form submissions older than %d days.',
count( $old_submissions ),
$retention_days
)
);
}
}
/**
* Look up form block attributes from post content by form ID.
*
* Searches published posts for a form-builder block with the matching formId
* attribute. This ensures email configuration is read from the server-side
* block definition, not from client-submitted data.
*
* @param string $form_id Form identifier to look up.
* @return array|null Block attributes array, or null if not found.
*/
private function get_form_block_attributes( $form_id ) {
// Preserve the legacy attributes cache for installations that already have
// it populated. New lookups use the complete definition cache below.
$legacy_cache = get_transient( 'airowp_form_attrs_v2_' . md5( $form_id ) );
if ( false !== $legacy_cache && is_array( $legacy_cache ) ) {
return $legacy_cache;
}
$form_definition = $this->get_form_definition( $form_id );
return null === $form_definition ? null : $form_definition['attributes'];
}
/**
* Resolve the server-owned definition for a public form.
*
* Parsing the post once keeps the validation schema, required flags, and
* submission configuration in sync. Only published posts are eligible: a
* private or draft form must not become a public submission endpoint just
* because its predictable ID is known. Forms that never live in wp_posts —
* block widgets, templates still served from theme or plugin files, and
* registered patterns — are resolved from those sources instead.
*
* Form IDs are not unique in practice: DSGo patterns ship fixed IDs, and a
* form copied between pages keeps its ID. When the submitting page is known
* and holds the form, that copy wins over whichever one the site-wide lookup
* would find first.
*
* @param string $form_id Form identifier to look up.
* @param int $source_post_id Page the form was submitted from, or 0.
* @return array{attributes: array, field_types: array, constraints: array, required_fields: string[], rules: array, labels?: array, conditions?: array{fields: string[], conditions: array}}|null Form definition or null.
*/
private function get_form_definition( $form_id, $source_post_id = 0 ) {
if ( ! is_string( $form_id ) || '' === $form_id ) {
return null;
}
if ( $source_post_id ) {
$source_definition = $this->get_source_post_form_definition( $form_id, $source_post_id );
if ( null !== $source_definition ) {
return $source_definition;
}
}
$cache_key = self::DEFINITION_CACHE_PREFIX . md5( $form_id );
$cached = get_transient( $cache_key );
if ( false !== $cached && is_array( $cached ) ) {
return $cached;
}
global $wpdb;
// Note: esc_like() wraps the entire concatenated string including $form_id,
// so LIKE wildcards (%, _) in $form_id are escaped. prepare() handles SQL injection.
// LIMIT 5 accounts for edge cases like revisions or duplicate formIds.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
$posts = $wpdb->get_results(
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
$wpdb->prepare(
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
"SELECT ID, post_content FROM {$wpdb->posts}
WHERE post_content LIKE %s
AND post_content LIKE %s
AND post_status = 'publish'
LIMIT 5",
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
'%' . $wpdb->esc_like( 'airo-wp/form-builder' ) . '%',
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
'%' . $wpdb->esc_like( '"formId":"' . $form_id . '"' ) . '%'
)
);
foreach ( (array) $posts as $post ) {
if ( ! $this->is_eligible_form_host( (int) $post->ID ) ) {
continue;
}
$blocks = parse_blocks( $post->post_content );
$form_block = $this->find_form_block( $blocks, $form_id );
if ( null !== $form_block ) {
$definition = $this->build_form_definition( $form_block );
set_transient( $cache_key, $definition, HOUR_IN_SECONDS );
return $definition;
}
}
return $this->get_external_form_definition( $form_id );
}
/**
* Whether a published post may serve its form to the site-wide lookup.
*
* This lookup ignores who is asking and its result is cached for everyone,
* so it cannot honour a visitor's password cookie: a password-protected
* post is never eligible. A form there is still reachable through
* get_source_post_form_definition(), which checks that cookie.
*
* Viewability is not required. The query already limits candidates to
* published posts, and this lookup is the only way to reach a form kept in
* a post type that is not publicly viewable but renders into public pages:
* synced patterns, templates and template parts, and theme builder or popup
* post types (GeneratePress and Kadence Elements, Blocksy Content Blocks,
* Spectra popups). Sites that want it narrower can use the filter.
*
* @param int $post_id Post ID.
* @return bool
*/
private function is_eligible_form_host( $post_id ) {
$post = get_post( $post_id );
if ( ! $post || '' !== $post->post_password ) {
return false;
}
/**
* Filters whether a published post may serve its form to submissions
* that name no source page.
*
* Password-protected posts never reach this filter. The result is cached
* with the form definition for an hour, so return the same answer for
* every visitor.
*
* @since 2.8.3
*
* @param bool $allowed Whether the post is eligible. Default true.
* @param \WP_Post $post Candidate post.
*/
return (bool) apply_filters( 'airowp_form_lookup_allows_post', true, $post );
}
/**
* Build the server-owned definition for a parsed form block.
*
* @param array $form_block Parsed airo-wp/form-builder block.
* @return array{attributes: array, field_types: array, constraints: array, required_fields: string[], rules: array, labels: array, conditions: array{fields: string[], conditions: array}} Form definition.
*/
private function build_form_definition( array $form_block ) {
$inner_blocks = isset( $form_block['innerBlocks'] ) ? $form_block['innerBlocks'] : array();
$field_types = $this->extract_field_types_from_blocks( $inner_blocks );
$labels = $this->extract_field_labels_from_blocks( $inner_blocks );
// The phone field's companion country-code select has no label of its own.
foreach ( $field_types as $name => $type ) {
$base = substr( $name, 0, -strlen( '_country_code' ) );
if ( 'country_code' === $type && isset( $labels[ $base ] ) ) {
/* translators: %s: phone field label */
$labels[ $name ] = sprintf( __( '%s (country code)', 'airo-wp' ), $labels[ $base ] );
}
}
return array(
'attributes' => $this->apply_form_block_defaults( $form_block['attrs'] ),
'field_types' => $field_types,
'constraints' => $this->extract_field_value_constraints_from_blocks( $inner_blocks ),
'required_fields' => $this->extract_required_field_names_from_blocks( $inner_blocks ),
'rules' => Form_Field_Rules::extract( $inner_blocks ),
'labels' => $labels,
'conditions' => Form_Conditions::extract( $inner_blocks ),
);
}
/**
* The page a submission was sent from, for the stored Source URL.
*
* A no-JS retry is posted from the previous result's URL, so drop the
* plugin's own result args: the record should name the page, not an
* earlier failure.
*
* @return string Referer without result args, or '' when there is none.
*/
private function get_submission_source_url() {
$referer = wp_get_referer();
if ( ! $referer ) {
return '';
}
return remove_query_arg( array( 'airowp_form_status', 'airowp_form_id', 'airowp_form_error', 'airowp_form_field' ), $referer );
}
/**
* Identify the page a submission was sent from.
*
* The view script sends the page ID it was localized with. Requests without one —
* the admin-post path, or a cached page running an older view.js — fall
* back to the referer. Either value is client-supplied, which is safe here
* because it only chooses between published copies of the form.
*
* @param WP_REST_Request $request Request object.
* @return int Post ID, or 0 when unknown.
*/
private function get_source_post_id( $request ) {
$source_post_id = absint( $request->get_param( 'sourcePostId' ) );
if ( $source_post_id ) {
return $source_post_id;
}
$referer = wp_get_referer();
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.url_to_postid_url_to_postid -- Not a VIP site; runs at most once per rate-limited submission, and only without sourcePostId.
return $referer ? url_to_postid( $referer ) : 0;
}
/**
* Resolve a form from the page it was submitted from.
*
* Deliberately uncached: it parses a single post, and caching per page
* would need its own invalidation for a lookup that is already cheap.
*
* The post ID is client-supplied, so it may only select a copy the requester
* could have seen and submitted anyway: a publicly viewable post, unlocked
* if it has a password. Every such copy is already a public endpoint, so
* naming it grants nothing that visiting that page wouldn't.
*
* @param string $form_id Form identifier to look up.
* @param int $post_id Source post ID.
* @return array|null Form definition, or null when that post isn't viewable to the requester or lacks the form.
*/
private function get_source_post_form_definition( $form_id, $post_id ) {
$post = get_post( $post_id );
if (
! $post ||
'publish' !== $post->post_status ||
! is_post_publicly_viewable( $post ) ||
post_password_required( $post ) ||
false === strpos( $post->post_content, 'airo-wp/form-builder' )
) {
return null;
}
$form_block = $this->find_form_block( parse_blocks( $post->post_content ), $form_id );
return null === $form_block ? null : $this->build_form_definition( $form_block );
}
/**
* Resolve a form that lives outside wp_posts.
*
* Block widgets, templates and template parts still served from theme or
* plugin files, and registered patterns (which templates pull in with
* wp:pattern) never appear in wp_posts. All of them are site-owner
* content. The index is built once and cached, so an unknown form ID costs
* a transient read here rather than a rescan of every template and pattern.
*
* @param string $form_id Form identifier to look up.
* @return array|null Form definition, or null when no such form exists.
*/
private function get_external_form_definition( $form_id ) {
$definitions = get_transient( self::EXTERNAL_DEFINITIONS_CACHE );
if ( ! is_array( $definitions ) ) {
$definitions = array();
foreach ( $this->get_external_block_content() as $content ) {
if ( ! is_string( $content ) || false === strpos( $content, 'airo-wp/form-builder' ) ) {
continue;
}
foreach ( $this->find_form_blocks( parse_blocks( $content ) ) as $form_block ) {
$id = (string) $form_block['attrs']['formId'];
if ( ! isset( $definitions[ $id ] ) ) {
$definitions[ $id ] = $this->build_form_definition( $form_block );
}
}
}
set_transient( self::EXTERNAL_DEFINITIONS_CACHE, $definitions, HOUR_IN_SECONDS );
}
return isset( $definitions[ $form_id ] ) ? $definitions[ $form_id ] : null;
}
/**
* Collect block content that isn't stored as a post.
*
* @return string[] Serialized block content.
*/
private function get_external_block_content() {
$contents = array();
foreach ( (array) get_option( 'widget_block', array() ) as $widget ) {
if ( is_array( $widget ) && isset( $widget['content'] ) ) {
$contents[] = $widget['content'];
}
}
if ( function_exists( 'get_block_templates' ) ) {
foreach ( array( 'wp_template', 'wp_template_part' ) as $template_type ) {
foreach ( get_block_templates( array(), $template_type ) as $template ) {
$contents[] = $template->content;
}
}
}
foreach ( \WP_Block_Patterns_Registry::get_instance()->get_all_registered() as $pattern ) {
if ( isset( $pattern['content'] ) ) {
$contents[] = $pattern['content'];
}
}
return $contents;
}
/**
* Recursively collect every form block that carries a form ID.
*
* @param array $blocks Parsed blocks.
* @return array[] Parsed airo-wp/form-builder blocks.
*/
private function find_form_blocks( $blocks ) {
$forms = array();
foreach ( $blocks as $block ) {
if (
'airo-wp/form-builder' === $block['blockName'] &&
isset( $block['attrs']['formId'] ) &&
is_string( $block['attrs']['formId'] ) &&
'' !== $block['attrs']['formId']
) {
$forms[] = $block;
}
if ( ! empty( $block['innerBlocks'] ) ) {
$forms = array_merge( $forms, $this->find_form_blocks( $block['innerBlocks'] ) );
}
}
return $forms;
}
/**
* Drop the cached index of forms outside wp_posts.
*
* Hooked to widget and theme changes; template edits are covered by
* clear_form_attributes_cache().
*/
public function clear_external_form_definitions() {
delete_transient( self::EXTERNAL_DEFINITIONS_CACHE );
}
/**
* Recursively find a form block with its inner-block schema intact.
*
* @param array $blocks Parsed blocks array.
* @param string $form_id Form identifier to match.
* @return array|null Matching parsed block.
*/
private function find_form_block( $blocks, $form_id ) {
foreach ( $blocks as $block ) {
if (
'airo-wp/form-builder' === $block['blockName'] &&
isset( $block['attrs']['formId'] ) &&
$form_id === $block['attrs']['formId']
) {
return $block;
}
if ( ! empty( $block['innerBlocks'] ) ) {
$result = $this->find_form_block( $block['innerBlocks'], $form_id );
if ( null !== $result ) {
return $result;
}
}
}
return null;
}
/**
* Merge registered block-type attribute defaults into a parsed attributes array.
*
* @param array $attrs Parsed block attributes from parse_blocks().
* @return array Attributes with block.json defaults filled in for missing keys.
*/
private function apply_form_block_defaults( $attrs ) {
$schemas = array();
$block_type = class_exists( '\WP_Block_Type_Registry' )
? \WP_Block_Type_Registry::get_instance()->get_registered( 'airo-wp/form-builder' )
: null;
if ( $block_type && is_array( $block_type->attributes ) ) {
$schemas = $block_type->attributes;
} elseif ( function_exists( 'wp_json_file_decode' ) ) {
$metadata = wp_json_file_decode(
dirname( __DIR__, 3 ) . '/src/blocks/form-builder/block.json',
array( 'associative' => true )
);
$schemas = is_array( $metadata ) && isset( $metadata['attributes'] ) && is_array( $metadata['attributes'] )
? $metadata['attributes']
: array();
}
foreach ( $schemas as $key => $schema ) {
if ( array_key_exists( $key, $attrs ) ) {
continue;
}
if ( is_array( $schema ) && array_key_exists( 'default', $schema ) ) {
$attrs[ $key ] = $schema['default'];
}
}
return $attrs;
}
/**
* Extract names of required fields from a form's inner blocks.
*
* @param array $blocks Parsed inner blocks.
* @return string[] Required field names.
*/
private function extract_required_field_names_from_blocks( $blocks ) {
$required_fields = array();
foreach ( $blocks as $block ) {
$attrs = isset( $block['attrs'] ) ? $block['attrs'] : array();
$field_name = isset( $attrs['fieldName'] ) ? sanitize_text_field( $attrs['fieldName'] ) : '';
$field_type = $this->map_block_name_to_field_type( isset( $block['blockName'] ) ? $block['blockName'] : '' );
if ( $field_name && $field_type && ! empty( $attrs['required'] ) ) {
$required_fields[] = $field_name;
}
if ( ! empty( $block['innerBlocks'] ) ) {
$required_fields = array_merge(
$required_fields,
$this->extract_required_field_names_from_blocks( $block['innerBlocks'] )
);
}
}
return array_values( array_unique( $required_fields ) );
}
/**
* Extract allowed values for constrained fields from a form's inner blocks.
*
* - select: the `value` of each entry in the `options` attribute.
* - checkbox: the single `value` attribute (default "1").
* - hidden: the server-defined `value` attribute (a constant).
*
* Fields without a constraint are not added to the map.
*
* @param array $blocks Parsed inner blocks.
* @return array Allowed values keyed by field name.
*/
private function extract_field_value_constraints_from_blocks( $blocks ) {
$constraints = array();
foreach ( $blocks as $block ) {
$block_name = isset( $block['blockName'] ) ? $block['blockName'] : '';
$field_name = isset( $block['attrs']['fieldName'] ) ? sanitize_text_field( $block['attrs']['fieldName'] ) : '';
$attrs = isset( $block['attrs'] ) ? $block['attrs'] : array();
if ( $field_name ) {
switch ( $block_name ) {
case 'airo-wp/form-select-field':
if ( isset( $attrs['options'] ) && is_array( $attrs['options'] ) ) {
$values = array();
foreach ( $attrs['options'] as $option ) {
if ( isset( $option['value'] ) ) {
$values[] = (string) $option['value'];
}
}
$constraints[ $field_name ] = $values;
}
break;
case 'airo-wp/form-checkbox-field':
$constraints[ $field_name ] = array(
isset( $attrs['value'] ) ? (string) $attrs['value'] : '1',
);
break;
case 'airo-wp/form-hidden-field':
$constraints[ $field_name ] = array(
isset( $attrs['value'] ) ? (string) $attrs['value'] : '',
);
break;
}
}
if ( ! empty( $block['innerBlocks'] ) ) {
$constraints = array_merge(
$constraints,
$this->extract_field_value_constraints_from_blocks( $block['innerBlocks'] )
);
}
}
return $constraints;
}
/**
* Extract field types from a form block's inner blocks.
*
* @param array $blocks Parsed inner blocks.
* @return array Field types keyed by field name.
*/
private function extract_field_types_from_blocks( $blocks ) {
$field_types = array();
foreach ( $blocks as $block ) {
$block_name = isset( $block['blockName'] ) ? $block['blockName'] : '';
$field_name = isset( $block['attrs']['fieldName'] ) ? sanitize_text_field( $block['attrs']['fieldName'] ) : '';
$field_type = $this->map_block_name_to_field_type( $block_name );
if ( $field_name && $field_type ) {
$field_types[ $field_name ] = $field_type;
// form-phone-field renders a companion