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