namespace, '/submit-form', [ 'methods' => WP_REST_Server::EDITABLE, 'callback' => [ $this, 'handle_form_submission' ], 'permission_callback' => [ $this, 'submit_form_permissions_check' ], ] ); register_rest_route( $this->namespace, '/log-client-error', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'handle_client_error_log' ], 'permission_callback' => [ $this, 'client_error_log_permissions_check' ], ] ); } /** * Record an integration failure against the form it happened on. * * Hooked - srfm_integration_failed. * * @param int $form_id Form the submission belongs to. * @param string $reason Short description of what failed. * @since 2.12.6 * @return void */ public function record_integration_failure( $form_id = 0, $reason = '' ) { $form_id = absint( $form_id ); Client_Logger::append( Client_Logger::sanitize_entry( [ 'type' => 'message', 'form_id' => $form_id, 'form_title' => $form_id ? Helper::get_string_value( get_the_title( $form_id ) ) : '', 'message' => 'Integration failed. ' . Helper::get_string_value( $reason ), ] ) ); Client_Logger::record_failure( 'integration', $form_id, $form_id ? Helper::get_string_value( get_the_title( $form_id ) ) : '' ); } /** * Gate the client error log route. * * Order matters. The enabled check runs first and returns 404 rather than 403, * because it is the only thing that actually stops logging: the frontend flag * is baked into cached HTML and can be a full cache TTL out of date, so * switching the setting off does not stop already-cached pages from posting. * * The submit token is then required for consistency with /submit-form, but be * clear about what it buys. It is per-form, not per-visitor, valid for up to * 48 hours, and readable from one GET of any public page carrying the form. It * filters undirected scanners and costs nothing; it is not visitor * authentication. The controls that carry real weight here are the fixed * payload schema in Client_Logger::sanitize_entry() and the rate limit below. * * @param \WP_REST_Request $request Incoming REST request. * @since 2.12.6 * @return WP_Error|bool */ public function client_error_log_permissions_check( $request ) { if ( ! Client_Logger::is_enabled() ) { return new WP_Error( 'srfm_rest_no_route', __( 'Not found.', 'sureforms' ), [ 'status' => 404 ] ); } $token = Helper::get_string_value( $request->get_header( 'X-WP-Submit-Token' ) ); $form_id = absint( $request->get_param( 'form_id' ) ); if ( ! Submit_Token::verify( $token, $form_id ) ) { return new WP_Error( 'srfm_token_invalid', __( 'Security verification failed.', 'sureforms' ), [ 'status' => 403 ] ); } return true; } /** * Record one client-reported form submission failure. * * Always answers 204, whether or not a line was written. The browser has * nothing useful to do with a failure here, and a response that distinguishes * "written" from "dropped" would report back whether logging is on, whether * the log is full, and whether the caller is being throttled. * * @param \WP_REST_Request $request Incoming REST request. * @since 2.12.6 * @return \WP_REST_Response */ public function handle_client_error_log( $request ) { $response = new \WP_REST_Response( null, 204 ); $form_id = absint( $request->get_param( 'form_id' ) ); if ( $this->is_rate_limited( 'srfm_cl_', $form_id ) ) { return $response; } $entries = $request->get_param( 'entries' ); if ( ! is_array( $entries ) ) { return $response; } // Cap the batch as well as each entry: a single request must not be able to // consume the whole file and evict the failure someone is trying to capture. foreach ( array_slice( $entries, 0, 10 ) as $raw ) { if ( ! is_array( $raw ) ) { continue; } $raw['form_id'] = $form_id; // Resolved here rather than sent by the browser: the title is what makes // a log line identifiable at a glance, and taking it from the request // would let a caller label an entry as any form it liked. $raw['form_title'] = $form_id ? Helper::get_string_value( get_the_title( $form_id ) ) : ''; Client_Logger::append( Client_Logger::sanitize_entry( $raw ) ); } return $response; } /** * Check whether a given request has permission to submit the form. * * Validates the HMAC-based submission token embedded in the page at render * time. Tokens remain valid for up to 48 hours (four 12-hour windows), so * they survive cached-page scenarios without any browser-side refresh call. * * @param \WP_REST_Request $request Incoming REST request. * @since 2.6.0 * @return WP_Error|bool */ public function submit_form_permissions_check( $request ) { $token = Helper::get_string_value( $request->get_header( 'X-WP-Submit-Token' ) ); $form_id = absint( $request->get_param( 'form-id' ) ); if ( ! Submit_Token::verify( $token, $form_id ) ) { return new WP_Error( 'srfm_token_invalid', __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ), [ 'status' => 403 ] ); } return true; } /** * Check whether a given request has permission access route. * * @since 0.0.1 * @return WP_Error|bool */ public function permissions_check() { if ( ! Helper::current_user_can() ) { return new WP_Error( 'rest_forbidden', __( 'Sorry, you do not have permission to access this resource.', 'sureforms' ), [ 'status' => rest_authorization_required_code() ] ); } return true; } /** * Validate Turnstile token * * @param string $secret_key Turnstile token. * @param string|false $response Response. * @param string|false $remote_ip Remote IP. * @return array|mixed Result of the validation. */ public static function validate_turnstile_token( $secret_key, $response, $remote_ip ) { if ( empty( $secret_key ) || ! is_string( $secret_key ) ) { return [ 'success' => false, 'error' => __( 'Cloudflare Turnstile secret key is invalid.', 'sureforms' ), ]; } if ( empty( $response ) ) { return [ 'success' => false, 'error' => __( 'Cloudflare Turnstile response is missing.', 'sureforms' ), ]; } $body = [ 'secret' => $secret_key, 'response' => $response, 'remoteip' => $remote_ip, ]; $url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; $args = [ 'body' => $body, 'timeout' => 15, ]; $response = wp_remote_post( $url, $args ); if ( is_wp_error( $response ) ) { $error_message = $response->get_error_message(); return [ 'success' => false, 'error' => $error_message, ]; } return json_decode( wp_remote_retrieve_body( $response ), true ); } /** * Validate hCaptcha token * * @param string $secret_key hCaptcha token. * @param string|false $response Response. * @param string|false $remote_ip Remote IP. * @since 0.0.5 * @return array|mixed Result of the validation. */ public static function validate_hcaptcha_token( $secret_key, $response, $remote_ip ) { if ( empty( $secret_key ) || ! is_string( $secret_key ) ) { return [ 'success' => false, 'error' => __( 'hCaptcha secret key is invalid.', 'sureforms' ), ]; } if ( empty( $response ) ) { return [ 'success' => false, 'error' => __( 'hCaptcha response is missing.', 'sureforms' ), ]; } $body = [ 'secret' => $secret_key, 'response' => $response, 'remoteip' => $remote_ip, ]; $url = 'https://api.hcaptcha.com/siteverify'; $args = [ 'body' => $body, 'timeout' => 15, ]; $response = wp_remote_post( $url, $args ); if ( is_wp_error( $response ) ) { $error_message = $response->get_error_message(); return [ 'success' => false, 'error' => $error_message, ]; } return json_decode( wp_remote_retrieve_body( $response ), true ); } /** * Handle Form Submission * * @param \WP_REST_Request $request Request object or array containing form data. * @since 0.0.1 * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. */ public function handle_form_submission( $request ) { $form_data = Helper::sanitize_by_field_type( $request->get_params() ); if ( empty( $form_data ) || ! is_array( $form_data ) ) { wp_send_json_error( [ 'message' => __( 'Form data is not found.', 'sureforms' ) ] ); } if ( empty( $form_data['form-id'] ) ) { wp_send_json_error( [ 'message' => __( 'Form ID is missing.', 'sureforms' ), 'position' => 'header', ] ); } $current_form_id = $form_data['form-id']; /** * If someone tries to access the form submit endpoint directly, we need to check if the form is restricted. * If a form is loaded in a browser window and the limit exceeds then the form will not be submitted. */ $form_id = Helper::get_integer_value( $current_form_id ); if ( Form_Restriction::is_form_restricted( $form_id ) ) { $form_restriction = Form_Restriction::get_form_restriction_setting( $form_id ); // Get the scheduling state and appropriate message. $scheduling_state = Form_Restriction::get_form_scheduling_state( $form_restriction ); $form_restriction_message = Form_Restriction::get_restriction_message_by_state( $scheduling_state, $form_restriction ); $form_restriction_message = apply_filters( 'srfm_form_restriction_message', $form_restriction_message, $form_id, $form_restriction ); wp_send_json_error( [ 'message' => $form_restriction_message, ] ); } if ( apply_filters( 'srfm_additional_restriction_check', false, $form_id, $form_data ) ) { wp_send_json_error( [ 'message' => apply_filters( 'srfm_additional_restriction_message', __( 'You do not have permission to submit this form.', 'sureforms' ), $form_id, $form_data ), ] ); } // Check whether the form is valid. if ( ! Helper::is_valid_form( $current_form_id ) ) { wp_send_json_error( [ 'code' => 'srfm_invalid_form_id', 'message' => __( 'This form is no longer available.', 'sureforms' ), ] ); } // Drop submitted keys this form does not define before anything consumes them. // Runs on SUBMISSION only, so historical entries whose keys no longer match a // rebuilt form (see #2665) stay fully readable on the read/export paths. $form_data = Field_Validation::strip_unknown_field_keys( $form_data, $current_form_id ); $validated_form_data = Field_Validation::validate_form_data( $form_data, $current_form_id ); if ( ! empty( $validated_form_data ) ) { // Get the first error message to display as the main message. $first_error = reset( $validated_form_data ); wp_send_json_error( [ 'message' => $first_error ?? __( 'Please check the form for errors.', 'sureforms' ), 'field_errors' => $validated_form_data, ] ); } $security_type = Helper::get_meta_value( Helper::get_integer_value( $current_form_id ), '_srfm_captcha_security_type' ); $selected_captcha_type = get_post_meta( Helper::get_integer_value( $current_form_id ), '_srfm_form_recaptcha', true ) ? Helper::get_string_value( get_post_meta( Helper::get_integer_value( $current_form_id ), '_srfm_form_recaptcha', true ) ) : ''; if ( 'none' !== $security_type ) { $global_setting_options = get_option( 'srfm_security_settings_options' ); } else { $global_setting_options = []; } if ( 'g-recaptcha' === $security_type ) { switch ( $selected_captcha_type ) { case 'v2-checkbox': $key = 'srfm_v2_checkbox_secret_key'; break; case 'v2-invisible': $key = 'srfm_v2_invisible_secret_key'; break; case 'v3-reCAPTCHA': $key = 'srfm_v3_secret_key'; break; default: $key = ''; break; } $google_captcha_secret_key = is_array( $global_setting_options ) && isset( $global_setting_options[ $key ] ) ? $global_setting_options[ $key ] : ''; } if ( 'cf-turnstile' === $security_type ) { // Turnstile validation. $srfm_cf_turnstile_secret_key = is_array( $global_setting_options ) && isset( $global_setting_options['srfm_cf_turnstile_secret_key'] ) ? Helper::get_string_value( $global_setting_options['srfm_cf_turnstile_secret_key'] ) : ''; $cf_response = ! empty( $form_data['cf-turnstile-response'] ) && is_string( $form_data['cf-turnstile-response'] ) ? $form_data['cf-turnstile-response'] : ''; // if gdpr is enabled then set remote ip to empty. $compliance = get_post_meta( Helper::get_integer_value( $current_form_id ), '_srfm_compliance', true ); $gdpr = false; if ( is_array( $compliance ) && is_array( $compliance[0] ) ) { $gdpr = ! empty( $compliance[0]['gdpr'] ) ? $compliance[0]['gdpr'] : false; } // check if ip logging is disabled in global settings then set remote ip to empty. $gb_general_settinionsgs_opt = get_option( 'srfm_general_settings_options' ); $srfm_ip_log = is_array( $gb_general_settinionsgs_opt ) && isset( $gb_general_settinionsgs_opt['srfm_ip_log'] ) ? $gb_general_settinionsgs_opt['srfm_ip_log'] : ''; $remote_ip = $gdpr || ( ! $srfm_ip_log ) ? '' : ( isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '' ); $turnstile_validation_result = self::validate_turnstile_token( $srfm_cf_turnstile_secret_key, $cf_response, $remote_ip ); // If the cloudflare validation fails, return an error. if ( is_array( $turnstile_validation_result ) && isset( $turnstile_validation_result['success'] ) && false === $turnstile_validation_result['success'] ) { $this->recaptcha_error_response( 'cf-turnstile', $turnstile_validation_result ); } } if ( 'hcaptcha' === $security_type ) { $srfm_hcaptcha_secret_key = is_array( $global_setting_options ) && isset( $global_setting_options['srfm_hcaptcha_secret_key'] ) ? Helper::get_string_value( $global_setting_options['srfm_hcaptcha_secret_key'] ) : ''; $hcaptcha_response = ! empty( $form_data['h-captcha-response'] ) && is_string( $form_data['h-captcha-response'] ) ? $form_data['h-captcha-response'] : ''; // if gdpr is enabled then set remote ip to empty. $compliance = get_post_meta( Helper::get_integer_value( $current_form_id ), '_srfm_compliance', true ); $gdpr = false; if ( is_array( $compliance ) && is_array( $compliance[0] ) ) { $gdpr = ! empty( $compliance[0]['gdpr'] ) ? $compliance[0]['gdpr'] : false; } // check if ip logging is disabled in global settings then set remote ip to empty. $gb_general_settings_options = get_option( 'srfm_general_settings_options' ); $srfm_ip_log = is_array( $gb_general_settings_options ) && isset( $gb_general_settings_options['srfm_ip_log'] ) ? $gb_general_settings_options['srfm_ip_log'] : ''; $remote_ip = $gdpr || ( ! $srfm_ip_log ) ? '' : ( isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : '' ); $hcaptcha_validation_result = self::validate_hcaptcha_token( $srfm_hcaptcha_secret_key, $hcaptcha_response, $remote_ip ); // If the hcaptcha validation fails, return an error. if ( is_array( $hcaptcha_validation_result ) && isset( $hcaptcha_validation_result['success'] ) && false === $hcaptcha_validation_result['success'] ) { $this->recaptcha_error_response( 'hcaptcha', $hcaptcha_validation_result ); } } if ( isset( $form_data['srfm-honeypot-field'] ) && empty( $form_data['srfm-honeypot-field'] ) ) { if ( ! empty( $google_captcha_secret_key ) ) { if ( ! empty( $form_data['form-id'] ) ) { $secret_key = $google_captcha_secret_key; $ipaddress = isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : ''; $captcha_response = $form_data['g-recaptcha-response']; $url = 'https://www.google.com/recaptcha/api/siteverify?secret=' . $secret_key . '&response=' . $captcha_response . '&ip=' . $ipaddress; $response = wp_remote_get( $url ); if ( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) === 200 ) { $json_string = wp_remote_retrieve_body( $response ); $data = (array) json_decode( $json_string, true ); } else { $data = []; } $sureforms_captcha_data = $data; } else { wp_send_json_error( [ 'message' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ), ] ); } if ( isset( $sureforms_captcha_data['success'] ) && true === $sureforms_captcha_data['success'] ) { return rest_ensure_response( $this->handle_form_entry( $form_data ) ); } $this->recaptcha_error_response( 'g-recaptcha', $sureforms_captcha_data ); } return rest_ensure_response( $this->handle_form_entry( $form_data ) ); } if ( ! isset( $form_data['srfm-honeypot-field'] ) ) { // If honeypot is enabled globally, the missing field means a bot stripped it. $srfm_security_options = get_option( 'srfm_security_settings_options' ); if ( is_array( $srfm_security_options ) && ! empty( $srfm_security_options['srfm_honeypot'] ) ) { wp_send_json_error( [ 'message' => __( 'Your submission was flagged as spam. Please try again.', 'sureforms' ), ] ); } if ( ! empty( $google_captcha_secret_key ) ) { if ( ! empty( $form_data['form-id'] ) ) { $secret_key = $google_captcha_secret_key; $ipaddress = isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : ''; $captcha_response = $form_data['g-recaptcha-response']; $url = 'https://www.google.com/recaptcha/api/siteverify?secret=' . $secret_key . '&response=' . $captcha_response . '&ip=' . $ipaddress; $response = wp_remote_get( $url ); if ( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) === 200 ) { $json_string = wp_remote_retrieve_body( $response ); $data = (array) json_decode( $json_string, true ); } else { $data = []; } $sureforms_captcha_data = $data; } else { wp_send_json_error( [ 'message' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ), ] ); } if ( true === $sureforms_captcha_data['success'] ) { return rest_ensure_response( $this->handle_form_entry( $form_data ) ); } $this->recaptcha_error_response( 'g-recaptcha', $sureforms_captcha_data ); } return rest_ensure_response( $this->handle_form_entry( $form_data ) ); } wp_send_json_error( [ 'message' => __( 'Your submission was flagged as spam. Please try again.', 'sureforms' ), ] ); } /** * Send Email and Create Entry. * * @param array $form_data Request object or array containing form data. * @since 0.0.1 * @return array Array containing the response data. */ public function handle_form_entry( $form_data ) { // Filter the form data. $form_data = apply_filters( 'srfm_form_submit_data', $form_data ); if ( empty( $form_data ) || ! is_array( $form_data ) ) { wp_send_json_error( [ 'message' => __( 'Form data was not found.', 'sureforms' ), 'position' => 'header', ] ); } elseif ( isset( $form_data['error'] ) ) { wp_send_json_error( [ 'message' => is_string( $form_data['error'] ) ? $form_data['error'] : __( 'Form data is not found.', 'sureforms' ), 'position' => 'header', ] ); } $id = sanitize_text_field( $form_data['form-id'] ); // Get the compliance settings. $compliance = get_post_meta( Helper::get_integer_value( $id ), '_srfm_compliance', true ); $gdpr = ''; $do_not_store_entries = ''; if ( is_array( $compliance ) && is_array( $compliance[0] ) ) { $gdpr = $compliance[0]['gdpr'] ?? ''; $do_not_store_entries = $compliance[0]['do_not_store_entries'] ?? ''; } // Check if the form data contains 'srfm_addresses' and is not empty. if ( ! empty( $form_data['srfm_addresses'] ) ) { // Assign the addresses to the class property for further processing. $this->addresses = $form_data['srfm_addresses']; // Remove the address data from the form data to avoid redundancy. unset( $form_data['srfm_addresses'] ); } $form_data = apply_filters( 'srfm_before_fields_processing', $form_data ); $submission_data = $this->process_form_fields( $form_data ); $modified_message = $this->prepare_submission_data( $submission_data ); $form_before_submission_data = [ 'form_id' => $id ? intval( $id ) : '', 'data' => $modified_message, ]; /** * Fires before submission process starts. */ do_action( 'srfm_before_submission', $form_before_submission_data ); $name = sanitize_text_field( get_the_title( intval( $id ) ) ); $emails = []; // Check if GDPR is enabled and do not store entries is enabled. // If so, send email and do not store entries. if ( $gdpr && $do_not_store_entries ) { // Send email before early return. No entry is created in this path so {entry_id} will be empty — that is expected. $send_email = $this->send_email( $id, $submission_data, $form_data ); if ( $send_email ) { $emails = $send_email['emails']; } $form_submit_response = [ 'success' => true, 'form_id' => $id ? intval( $id ) : '', 'to_emails' => $emails, 'form_name' => $name ? esc_attr( $name ) : '', 'message' => Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data ), 'data' => $modified_message, ]; do_action( 'srfm_form_submit', $form_submit_response ); /** * Hook for enabling background processes. * * @param array $form_data form data related to submission. */ $form_data['form_id'] = $id ? intval( $id ) : ''; do_action( 'srfm_after_submission_process', $form_data ); return [ 'success' => true, 'message' => Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data ), 'data' => [ 'name' => $name, 'after_submit' => false, ], 'redirect_url' => Generate_Form_Markup::get_redirect_url( $form_data, $submission_data ), ]; } $global_setting_options = get_option( 'srfm_general_settings_options' ); // If GDPR is enabled, do not store IP, browser, device, and submission URL. // If not, store all of them. $user_ip = ''; $browser_name = ''; $device_name = ''; $submission_url = ''; if ( ! $gdpr ) { $srfm_ip_log = is_array( $global_setting_options ) && isset( $global_setting_options['srfm_ip_log'] ) ? $global_setting_options['srfm_ip_log'] : ''; $user_ip = $srfm_ip_log && isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : ''; $browser = new Browser(); $browser_name = sanitize_text_field( $browser->getBrowser() ); $device_name = sanitize_text_field( $browser->getPlatform() ); // Capture submission page URL server-side from the Referer header. // esc_url_raw() (not sanitize_text_field) preserves percent-encoded // non-ASCII slugs; normalize_submission_url() then validates same-origin. $referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : ''; $submission_url = $this->normalize_submission_url( $referer ); } $form_markup = get_the_content( null, false, Helper::get_integer_value( $form_data['form-id'] ) ); $pattern = '/"label":"(.*?)"/'; preg_match_all( $pattern, $form_markup, $matches ); $submission_info = [ 'user_ip' => $user_ip, 'browser_name' => $browser_name, 'device_name' => $device_name, 'submission_url' => $submission_url, ]; // Resolve the language the visitor saw at form-render time (captured in a // hidden srfm-form-language input) so the confirmation message and email // notifications below can be rendered in it — WPML's language detection on // the REST submit endpoint frequently falls back to the default. This value // is used only to switch_language() at submit time; it is not persisted. The // hidden input is client-supplied, so: // 1. Validate shape with a BCP-47 regex. // 2. Cross-check against the active multilingual provider's known // languages (active + default) so a crafted request can't switch rendering // to a code the site doesn't support. // 3. Fall back to the provider's current_language() on either failure. $entry_language = Multilingual_Manager::get_instance()->provider()->current_language(); $submitted_language = isset( $form_data['srfm-form-language'] ) ? sanitize_text_field( Helper::get_string_value( $form_data['srfm-form-language'] ) ) : ''; if ( '' !== $submitted_language && preg_match( '/^[a-z]{2,3}([_-][A-Za-z0-9]{2,8})?$/', $submitted_language ) === 1 && $this->is_known_language( $submitted_language ) ) { $entry_language = $submitted_language; } $entries_data = [ 'form_id' => $id, 'form_data' => $submission_data, 'submission_info' => $submission_info, 'created_at' => current_time( 'mysql' ), ]; // Resolved via Helper rather than get_current_user_id() directly: this runs on // a REST request that carries no nonce, which core de-authenticates before // dispatch, so the plain call returns 0 even for a signed-in submitter and the // entry would lose its attribution. Returns 0 when genuinely anonymous. $submitting_user_id = Helper::get_submitting_user_id(); if ( $submitting_user_id ) { $entries_data['user_id'] = $submitting_user_id; } $entries_data = apply_filters( 'srfm_before_entry_data', $entries_data, [ 'form_data' => $form_data, 'submission_data' => $submission_data, ] ); $entry_id = Entries::add( $entries_data ); if ( $entry_id ) { // Inject entry_id so {entry_id} smart tag resolves in confirmation message, redirect URL, email notifications, and downstream integrations. $form_data['entry_id'] = intval( $entry_id ); // Switch the multilingual provider to the entry's language so the // confirmation message, redirect URL, and email notifications render // in the language the visitor saw at submit time. The REST submit // endpoint doesn't carry the ?lang= URL parameter, so without this // switch the provider would return strings in its default language // even though the visitor filled the form in another language. $provider = Multilingual_Manager::get_instance()->provider(); if ( $provider->is_active() && '' !== $entry_language ) { $provider->switch_language( $entry_language ); } // Send email after entry creation so {entry_id} is available when smart tags are processed. $send_email = $this->send_email( $id, $submission_data, $form_data ); if ( $send_email ) { $emails = $send_email['emails']; } $confirmation_message = Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data ); $redirect_url = Generate_Form_Markup::get_redirect_url( $form_data, $submission_data ); if ( $provider->is_active() && '' !== $entry_language ) { $provider->restore_language(); } $after_submit_nonce = wp_create_nonce( 'srfm_after_submission_' . Helper::get_string_value( $entry_id ) ); $response = [ 'success' => true, 'message' => $confirmation_message, 'data' => [ 'name' => $name, 'submission_id' => $entry_id, 'after_submit' => true, 'after_submit_nonce' => $after_submit_nonce, // Built here rather than assembled in JS. rest_url() already knows // whether the route is a path or a `?rest_route=` query arg, and // add_query_arg() knows whether the nonce needs `?` or `&` — the // client has no way to get either right without reimplementing // both, and concatenating produced a URL that did not route at all // on plain-permalink sites. 'after_submit_url' => add_query_arg( 'after_submit_nonce', $after_submit_nonce, rest_url( 'sureforms/v1/after-submission/' . Helper::get_integer_value( $entry_id ) ) ), ], 'redirect_url' => $redirect_url, ]; $form_submit_response = apply_filters( 'srfm_form_submit_response', [ 'success' => true, 'form_id' => $id ? intval( $id ) : '', 'entry_id' => intval( $entry_id ), 'to_emails' => $emails, 'form_name' => $name ? esc_attr( $name ) : '', 'message' => $confirmation_message, 'data' => $modified_message, ] ); do_action( 'srfm_form_submit', $form_submit_response ); } else { $response = [ 'success' => false, 'message' => __( 'Unable to submit form. Please try again.', 'sureforms' ), ]; } /** * Filter the form submission response. * * @param array $response The response data. * @param array $form_data The original form data. * @param array $submission_data The processed submission data. * @since 2.4.0 */ return apply_filters( 'srfm_form_submission_response', $response, $form_data, $submission_data ); } /** * Prepare submission data. * * @param array $submission_data Submission data. * @since 0.0.7 * @return array Modified submission data. */ public function prepare_submission_data( $submission_data ) { $modified_message = []; foreach ( $submission_data as $key => $value ) { $parts = explode( '-lbl-', $key ); $label = ''; /** * Filters submission data for field processing. * * This filter allows customization of how individual fields are processed * during submission data preparation. Plugins can modify field values, * labels, or exclude specific fields from the final submission data. * * @since 1.11.0 * * @param array $field_data { * Field data for processing. * * @type array $block_parts The field key split by '-lbl-' delimiter. * @type string $field_key The original field key from submission data. * @type mixed $field_value The field value from submission data. * } */ $should_add_field_row = apply_filters( 'srfm_prepare_submission_data', [ 'block_parts' => $parts, 'field_key' => $key, 'field_value' => $value, ] ); // If we get the label and value from the filter, then use it. if ( ! empty( $should_add_field_row['label'] ) && ! empty( $should_add_field_row['value'] ) ) { $modified_message[ $should_add_field_row['label'] ] = $should_add_field_row['value']; continue; } if ( ! empty( $parts[1] ) ) { $tokens = explode( '-', $parts[1] ); if ( count( $tokens ) > 1 ) { $label = implode( '-', array_slice( $tokens, 1 ) ); } $fields = explode( '-', $parts[0] ); // Since the upload field returns an array of file URLs, we need to implode them with a comma. if ( 'upload' === $fields[1] && ! empty( $value ) && is_array( $value ) ) { $modified_message[ $label ] = implode( ', ', array_map( 'rawurldecode', $value ) ); } else { $modified_message[ $label ] = html_entity_decode( esc_attr( Helper::get_string_value( $value ) ) ); } } } // If the address is not empty, add it to the submission data. // We are providing this for third-party integrations like Ottokit. // They can use compact addresses such as permanent address, temporary address, etc. // The address will be structured as field 1, field 2, and so on. if ( ! empty( $this->addresses ) ) { // Address will be JSON stringified, so decode it. $address = json_decode( wp_unslash( $this->addresses ), true ); if ( ! empty( $address ) && is_array( $address ) ) { $modified_message = array_merge( $modified_message, $address ); } } return apply_filters( 'srfm_update_prepared_submission_data', $modified_message ); } /** * Parse an email notification template and generate the necessary components for sending an email. * * @param array $submission_data An associative array containing submission data to be used in the email template. * @param array $item An associative array containing email settings, such as 'email_to', 'subject', 'email_body', and optional headers like 'email_reply_to', 'email_cc', and 'email_bcc'. * @param array $form_data Request object or array containing form data. * @since 1.3.0 * @return array An associative array containing 'to', 'subject', 'message', and 'headers' for the email. */ public static function parse_email_notification_template( $submission_data, $item, $form_data = [] ) { $smart_tags = Smart_Tags::get_instance(); $to = Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_to'], $submission_data ) ); $subject = Helper::get_string_value( $smart_tags->process_smart_tags( $item['subject'], $submission_data, $form_data ) ); $email_body = Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_body'], $submission_data, $form_data ) ); $is_raw_format = isset( $item['is_raw_format'] ) && true === $item['is_raw_format']; /** * Sanitize the email body after smart tag substitution to prevent XSS. * * After process_smart_tags() resolves {form:slug} placeholders, the body may contain * raw user-submitted values that must not render as executable HTML in email clients. * wp_kses_post() strips dangerous markup (script, on* handlers, javascript: URIs) * while preserving all legitimate email formatting (tables, links, bold, etc.). * * Note: {all_data} is not a recognised smart tag and remains a literal placeholder * at this point; it is substituted later by process_all_data_tag() which applies * its own per-field escaping, so this call does not interfere with that path. * * @since 2.5.2 */ $email_body = wp_kses_post( $email_body ); $email_template = new Email_Template(); $message = $is_raw_format ? $email_template->render_raw( $submission_data, $email_body ) : $email_template->render( $submission_data, $email_body ); $headers = 'X-Mailer: PHP/' . phpversion() . "\r\n"; $headers .= "Content-Type: text/html; charset=utf-8\r\n"; // Add the From: to the headers. $headers .= self::add_from_data_in_header( $submission_data, $item, $smart_tags ); // Handle Reply-To with proper sanitization. if ( isset( $item['email_reply_to'] ) && ! empty( $item['email_reply_to'] ) ) { $headers .= 'Reply-To: ' . Helper::sanitize_email_header( Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_reply_to'], $submission_data ) ) ) . "\r\n"; } // Handle CC with proper sanitization. if ( isset( $item['email_cc'] ) && ! empty( $item['email_cc'] ) ) { $headers .= 'Cc: ' . Helper::sanitize_email_header( Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_cc'], $submission_data ) ) ) . "\r\n"; } // Handle BCC with proper sanitization. if ( isset( $item['email_bcc'] ) && ! empty( $item['email_bcc'] ) ) { $headers .= 'Bcc: ' . Helper::sanitize_email_header( Helper::get_string_value( $smart_tags->process_smart_tags( $item['email_bcc'], $submission_data ) ) ) . "\r\n"; } return compact( 'to', 'subject', 'message', 'headers' ); } /** * Send Email. * * @param string $id Form ID. * @param array $submission_data Submission data. * @param array $form_data Request object or array containing form data. * @since 0.0.1 * @return array Array containing the response data. */ public static function send_email( $id, $submission_data, $form_data = [] ) { $email_notification = get_post_meta( intval( $id ), '_srfm_email_notification' ); $is_mail_sent = false; // Any recipient failing counts as a failure for the whole submission, so // these are set inside the loop and only read after it. $notification_failed = false; // Whether any recipient's "success" came from the mail() fallback, which // reports true for a message the local MTA accepted and will bounce. $used_mail_fallback = false; $emails = []; // Filter to determine whether the email notification should be sent. $email_notification = apply_filters( 'srfm_email_notification_should_send', $email_notification, $submission_data, $form_data ); if ( is_iterable( $email_notification ) ) { $entries_db_instance = Entries::get_instance(); $log_key = $entries_db_instance->add_log( __( 'Email notification passed to the sending server', 'sureforms' ) ); foreach ( $email_notification as $notification ) { foreach ( $notification as $item ) { if ( true === $item['status'] ) { $parsed = self::parse_email_notification_template( $submission_data, $item, $form_data ); // Allow filtering of the email data before it is sent. $parsed = apply_filters( 'srfm_email_notification', $parsed, $submission_data, $item, $form_data ); // Trigger an action before sending the email, allowing additional processing or logging. do_action( 'srfm_before_email_send', $parsed, $submission_data, $item, $form_data ); $notification_id = isset( $item['id'] ) ? intval( $item['id'] ) : 0; /** * Filter to determine whether the email should be sent. * * @since 1.10.1 */ $should_send_email = apply_filters( 'srfm_should_send_email', true, $notification_id, $id, $form_data, ); if ( ! wp_validate_boolean( $should_send_email ) ) { continue; } /** * Temporary override the content type for wp_mail. * This helps us from breaking of content type from other plugins. * * @since 1.2.2 */ add_filter( 'wp_mail_content_type', static function() { return 'text/html'; // We need "text/html" content type to render our emails. }, 99 ); /** * Start sending email. * Wrapping it in the buffer because when some plugin such as zoho mail, overrides the wp_mail * function and any exception is thrown ( Or printed ) from that plugin side, it affects the JSON response. * So, to make sure such exceptions doesn't affect our JSON response, we are wrapping it inside buffer. * * Try-Catch does not work because the notice or errors might be echoed by other plugins rather than thrown as an exception. * * @since 1.2.2 */ $sent = false; ob_start(); $sent = wp_mail( $parsed['to'], $parsed['subject'], $parsed['message'], $parsed['headers'] ); if ( ! $sent ) { // Fallback to default PHP mail if for some reasons wp_mail fails. $sent = mail( $parsed['to'], $parsed['subject'], $parsed['message'], $parsed['headers'] ); if ( $sent ) { // Accepted by the local MTA, not delivered. Good // enough to avoid recording a fault, not good // enough to retire one. $used_mail_fallback = true; } } $email_report = ob_get_clean(); // Catch any printed notice/errors/message for reports. if ( true !== $sent ) { $notification_failed = true; } if ( is_int( $log_key ) ) { if ( true === $sent ) { $entries_db_instance->update_log( $log_key, null, [ /* translators: Here, %s is the comma separated emails list. */ sprintf( __( 'Email notification recipient: %s', 'sureforms' ), esc_html( $parsed['to'] ) ), ] ); } else { $reason = ! empty( $email_report ) ? esc_html( $email_report ) : ( ! Helper::is_any_smtp_plugin_active() ? esc_html__( 'No SMTP plugin detected. Please configure an SMTP plugin to enable email sending.', 'sureforms' ) : esc_html__( 'Email sending failed for an unknown reason.', 'sureforms' ) ); $entries_db_instance->update_log( $log_key, null, [ sprintf( /* translators: Here, %1$s is the comma separated emails list and %2$s is error report ( if any ). */ __( 'Email server was unable to send the email notification. Recipient: %1$s. Reason: %2$s', 'sureforms' ), esc_html( $parsed['to'] ), $reason ), ] ); // Also record it in the debug log. The submission itself // succeeded, so the visitor saw nothing wrong and nobody // looks at the entry's own log until a ticket is already // open. The recipient address is not included -- the log // is downloadable and must not carry personal data. Client_Logger::append( Client_Logger::sanitize_entry( [ 'type' => 'message', 'form_id' => intval( $id ), 'form_title' => Helper::get_string_value( get_the_title( intval( $id ) ) ), 'message' => 'Email notification failed to send. ' . $reason, ] ) ); // Its own category: the entry saved, so this is not a // submission failure. The site owner is simply not being // told about entries they did receive. Client_Logger::record_failure( 'notification', intval( $id ), Helper::get_string_value( get_the_title( intval( $id ) ) ) ); } } // Trigger an action after the email is sent, allowing additional processing or logging. do_action( 'srfm_after_email_send', $parsed, $submission_data, $item, $form_data ); $is_mail_sent = $sent; $emails[] = $parsed['to']; } } } if ( empty( $emails ) ) { $entries_db_instance->reset_logs(); $entries_db_instance->add_log( __( 'No emails were sent.', 'sureforms' ) ); } // The notification fault clears when notifications work again. Nothing // else retired it: Client_Logger::clear_category() had a single caller // hardcoded to 'submission', and the notice is deliberately not // dismissible, so a site that had fixed its SMTP kept an undismissable // banner on every admin page until somebody opened a support ticket. // Held until the loop is done because one recipient succeeding while // another fails is still a failure. // // Scoped to the form the fault was recorded against. send_email() runs // on the public submit path and the counter is per category, not per // form, so without this an anonymous submission of a working form // wipes a different form's standing fault -- once per admin page load, // by anyone. The notice names a form, so the granularity is visible // now that this clears as well as records. // // wp_mail() only. The mail() fallback above returns true when the local // MTA merely accepts a message it will later bounce, which is the // broken configuration rather than the fixed one. // // is_int( $log_key ) mirrors the recording guard: record_failure() sits // inside it, so without it an install where add_log() returns a // non-int would never record a notification fault but would still // clear one. $open_failures = Client_Logger::get_failures(); if ( ! empty( $emails ) && ! $notification_failed && is_int( $log_key ) && ! $used_mail_fallback && intval( $id ) === Helper::get_integer_value( $open_failures['notification']['form_id'] ?? 0 ) ) { Client_Logger::clear_category( 'notification' ); } } return [ 'success' => $is_mail_sent, 'emails' => $emails, ]; } /** * Validate unique field values for a specific form via AJAX. * * Checks submitted field values against existing entries to determine * if duplicates exist. Rate-limited to prevent data enumeration. * * @since 0.0.1 * @since 2.7.0 Added rate limiting, form validation, and optimized query. * @return void */ public function field_unique_validation() { $token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verification replaces nonce. $form_id = isset( $_POST['id'] ) ? absint( wp_unslash( $_POST['id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( ! Submit_Token::verify( $token, $form_id ) ) { wp_send_json_error( [ 'error' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) ] ); } if ( ! $form_id ) { wp_send_json_error( [ 'error' => __( 'Invalid form ID.', 'sureforms' ) ] ); } // Validate the form exists and is published to prevent cross-form probing. if ( 'publish' !== get_post_status( $form_id ) || 'sureforms_form' !== get_post_type( $form_id ) ) { wp_send_json_error( [ 'error' => __( 'Invalid form.', 'sureforms' ) ] ); } // Rate limit: 10 requests per minute per IP per form. if ( $this->is_unique_validation_rate_limited( $form_id ) ) { wp_send_json_error( [ 'error' => __( 'Too many requests. Please try again shortly.', 'sureforms' ) ], 429 ); } // SECURITY INVARIANT — only the fields the form itself marks unique may be // probed through this unauthenticated handler. The allowlist is what keeps the // lookup scoped to values a site owner opted into checking, rather than to // stored submission data generally. A form with no unique fields therefore // matches nothing and always answers with an empty set. $unique_block_ids = $this->get_unique_field_block_ids( $form_id ); // Extract and validate field values from POST data. $skip_keys = [ 'action', 'token', 'id' ]; $duplicates = []; foreach ( $_POST as $raw_key => $raw_value ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verified above. if ( in_array( $raw_key, $skip_keys, true ) ) { continue; } $field_key = str_replace( '_', ' ', sanitize_text_field( $raw_key ) ); $value = sanitize_text_field( wp_unslash( $raw_value ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verified above. // Only process SureForms field keys (they contain -lbl- in the name). if ( false === strpos( $field_key, '-lbl-' ) ) { continue; } if ( '' === $value ) { continue; } // The key must resolve to a block this form configured as unique. $block_id = Helper::get_block_id_from_key( $field_key ); if ( '' === $block_id || ! isset( $unique_block_ids[ $block_id ] ) ) { continue; } // Single optimized query per field instead of loading all entries. if ( Entries::has_duplicate_field_value( $form_id, $field_key, $value ) ) { $duplicates[] = [ $field_key => 'not unique' ]; } } wp_send_json( [ 'data' => $duplicates ] ); } /** * Function to save allowed block data. * * @since 0.0.1 * @return void */ public function srfm_global_update_allowed_block() { if ( ! Helper::current_user_can() ) { wp_send_json_error(); } if ( ! check_ajax_referer( 'srfm_ajax_nonce', 'security', false ) ) { wp_send_json_error(); } if ( ! empty( $_POST['defaultAllowedQuickSidebarBlocks'] ) ) { $srfm_default_allowed_quick_sidebar_blocks = json_decode( sanitize_text_field( wp_unslash( $_POST['defaultAllowedQuickSidebarBlocks'] ) ), true ); Helper::update_admin_settings_option( 'srfm_quick_sidebar_allowed_blocks', $srfm_default_allowed_quick_sidebar_blocks ); wp_send_json_success(); } wp_send_json_error(); } /** * Function to save enable/disable data. * * @since 0.0.1 * @return void */ public function srfm_global_sidebar_enabled() { if ( ! Helper::current_user_can() ) { wp_send_json_error(); } if ( ! check_ajax_referer( 'srfm_ajax_nonce', 'security', false ) ) { wp_send_json_error(); } if ( ! empty( $_POST['enableQuickActionSidebar'] ) ) { $srfm_enable_quick_action_sidebar = ( 'enabled' === $_POST['enableQuickActionSidebar'] ? 'enabled' : 'disabled' ); Helper::update_admin_settings_option( 'srfm_enable_quick_action_sidebar', $srfm_enable_quick_action_sidebar ); wp_send_json_success(); } wp_send_json_error(); } /** * Send error response for reCAPTCHA validation failure. * * @param string $type The type of CAPTCHA used. Accepted values: 'g-recaptcha', 'hcaptcha', 'cf-turnstile'. * @param array $api_response The response returned from the CAPTCHA validation API. * @since 1.7.0 * @return void */ public function recaptcha_error_response( $type, $api_response ) { $error_message = $this->recaptcha_error_message( $type, $api_response ); $response = array_merge( [ 'api_response' => $api_response, ], $error_message ); wp_send_json_error( $response ); } /** * Get the error message for a CAPTCHA validation failure based on the service type and API response. * * @param string $type The type of CAPTCHA used. Accepted values: 'g-recaptcha', 'hcaptcha', 'cf-turnstile'. * @param array $api_response The response returned from the CAPTCHA validation API. * @since 1.7.0 * @return array An associative array containing the error message and a detailed message. */ public function recaptcha_error_message( $type, $api_response ) { if ( empty( $api_response['error-codes'] ) || ! is_array( $api_response['error-codes'] ) ) { return [ 'detail_message' => __( 'Captcha validation failed. No error code provided.', 'sureforms' ), 'message' => __( 'Captcha validation failed.', 'sureforms' ), ]; } /** * Note: The error codes are not translated because these messages are intended for debugging purposes. * Translating them would make debugging difficult. These error messages are primarily for developers or administrators. * A generic message will be displayed to the user, while detailed error information will be logged or shown in the console. */ // Google reCAPTCHA error codes. // Reference: (https://developers.google.com/recaptcha/docs/verify#error-code-reference). $google_recaptcha_error = [ 'missing-input-secret' => 'The secret parameter is missing.', 'invalid-input-secret' => 'The secret parameter is invalid or malformed.', 'missing-input-response' => 'The response parameter is missing.', 'invalid-input-response' => 'The response parameter is invalid or malformed.', 'bad-request' => 'The request is invalid or malformed.', 'timeout-or-duplicate' => 'The response is no longer valid: either is too old or has been used previously.', ]; // hCaptcha error codes. // Reference: (https://docs.hcaptcha.com/#siteverify-error-codes). $hcaptcha_errors = [ 'missing-input-secret' => 'Your secret key is missing.', 'invalid-input-secret' => 'Your secret key is invalid or malformed.', 'missing-input-response' => 'The response parameter (verification token) is missing.', 'invalid-input-response' => 'The response parameter (verification token) is invalid or malformed.', 'expired-input-response' => 'The response parameter (verification token) is expired. (120s default)', 'already-seen-response' => 'The response parameter (verification token) was already verified once.', 'bad-request' => 'The request is invalid or malformed.', 'missing-remoteip' => 'The remoteip parameter is missing.', 'invalid-remoteip' => 'The remoteip parameter is not a valid IP address or blinded value.', 'not-using-dummy-passcode' => 'You have used a testing sitekey but have not used its matching secret.', 'sitekey-secret-mismatch' => 'The sitekey is not registered with the provided secret.', ]; // Cloudflare Turnstile error codes. // Reference: (https://developers.cloudflare.com/turnstile/get-started/server-side-validation/). $cf_turnstile_errors = [ 'missing-input-secret' => 'The secret parameter was not passed.', 'invalid-input-secret' => 'The secret parameter was invalid, did not exist, or is a testing secret key with a non-testing response.', 'missing-input-response' => 'The response parameter (token) was not passed.', 'invalid-input-response' => 'The response parameter (token) is invalid or has expired. Most of the time, this means a fake token has been used. If the error persists, contact customer support.', 'bad-request' => 'The request was rejected because it was malformed.', 'timeout-or-duplicate' => 'The response parameter (token) has already been validated before. This means that the token was issued five minutes ago and is no longer valid, or it was already redeemed.', 'internal-error' => 'An internal error happened while validating the response. The request can be retried.', ]; $error_code = $api_response['error-codes'][0] ?? 'no-error-code'; $captcha_title = ''; $captcha_message = ''; switch ( $type ) { case 'g-recaptcha': $captcha_title = __( 'Google reCAPTCHA', 'sureforms' ); $captcha_message = $google_recaptcha_error[ $error_code ]; break; case 'hcaptcha': $captcha_title = __( 'hCaptcha', 'sureforms' ); $captcha_message = $hcaptcha_errors[ $error_code ]; break; case 'cf-turnstile': $captcha_title = __( 'Cloudflare Turnstile', 'sureforms' ); $captcha_message = $cf_turnstile_errors[ $error_code ]; break; default: $captcha_title = __( 'Unknown Captcha', 'sureforms' ); $captcha_message = __( 'Invalid captcha type.', 'sureforms' ); break; } $detail_message = sprintf( '%s: %s
Error Code: %s', $captcha_title, $captcha_message ?? 'Unknown error occurred.', $error_code ); $message = sprintf( /* translators: %s is the captcha title. */ __( '%s verification failed. Please contact your site administrator.', 'sureforms' ), $captcha_title ); return [ 'log_message' => $detail_message, // This variable is used for logging purposes, such as displaying detailed error information in the console on the front end. 'message' => $message, ]; } /** * Sanitise and validate a Referer into a storable submission URL. * * The value is rebuilt from parsed components so a non-browser client cannot * inject bits a real browser would never send (userinfo, fragment) or mismatch * the legitimate origin's port. Anything that is not a same-origin http(s) URL, * or is longer than 2048 chars, is rejected and returns an empty string. * * Uses esc_url_raw() rather than sanitize_text_field(): the latter strips * percent-encoded octets (`%E0%A4...`), which mangles the URLs of translated * pages whose slugs contain non-ASCII characters (e.g. WPML Hindi/Arabic * permalinks) down to bare hyphens. esc_url_raw() preserves the percent-encoding * so the recorded submission URL stays accurate. * * @param string $referer Raw (unslashed) Referer header value. * @since 2.11.0 * @return string Same-origin http(s) URL, or empty string when invalid. */ protected function normalize_submission_url( string $referer ): string { $referer = esc_url_raw( $referer ); if ( '' === $referer || strlen( $referer ) > 2048 ) { return ''; } $parts = wp_parse_url( $referer ); $home_parts = wp_parse_url( home_url() ); if ( ! is_array( $parts ) || ! is_array( $home_parts ) || ! isset( $parts['scheme'], $parts['host'], $home_parts['host'] ) || ! in_array( strtolower( $parts['scheme'] ), [ 'http', 'https' ], true ) || 0 !== strcasecmp( (string) $parts['host'], (string) $home_parts['host'] ) || ( $parts['port'] ?? null ) !== ( $home_parts['port'] ?? null ) ) { return ''; } $clean = $parts['scheme'] . '://' . $parts['host'] . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ) . ( $parts['path'] ?? '' ) . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' ); return esc_url_raw( $clean, [ 'http', 'https' ] ); } /** * Check whether the given language code is known to the active multilingual * provider (i.e. in its active-languages set or matches the default language). * * Used to reject crafted srfm-form-language hidden-input values that pass * the BCP-47 shape regex but reference languages the site doesn't actually * support. * * @param string $language Language code to check (e.g. 'hi', 'de-AT'). * @since 2.11.0 * @return bool True when the code is known, false otherwise. */ protected function is_known_language( string $language ): bool { if ( '' === $language ) { return false; } $provider = Multilingual_Manager::get_instance()->provider(); // When no provider is active there's no authoritative set to check // against. Accept whatever the visitor sent (shape-validated) so the // column still reflects the visitor's intent on non-WPML sites. if ( ! $provider->is_active() ) { return true; } // Default language is always considered known. if ( $language === $provider->default_language() ) { return true; } // Use WPML's filter when available — works regardless of which // multilingual plugin is the active provider, as Polylang implements // the same filter for compatibility. $active = apply_filters( 'wpml_active_languages', null, 'skip_missing=0' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's own filter; the name must match WPML/Polylang exactly to integrate. if ( is_array( $active ) && ! empty( $active ) ) { return array_key_exists( $language, $active ); } // A provider IS active but its language list is unavailable. Rather than // fail open and trust an arbitrary client-supplied code, accept it only when // it matches the server-resolved current language. The caller already // defaults $entry_language to current_language(), so this keeps mis-tagging // to the server's own determination instead of the (cacheable) client value. return $language === $provider->current_language(); } /** * Collect the block IDs of the fields a form configures as unique. * * Derived from the stored form, never from the request — the whole point is that * the client cannot nominate which fields are probeable. The frontend already * sends only inputs rendered with data-unique="true", which comes from the same * isUnique attribute, so this is the server-side mirror of what the client does. * * @param int $form_id Form ID. * * @since 2.12.3 * @return array Unique field block IDs, keyed by block ID. */ private function get_unique_field_block_ids( $form_id ) { $form = get_post( $form_id ); if ( ! $form instanceof \WP_Post || '' === $form->post_content ) { return []; } $visited_refs = []; $block_ids = $this->collect_unique_field_block_ids( parse_blocks( $form->post_content ), $visited_refs ); /** * Filters the block IDs treated as unique fields for the AJAX uniqueness check. * * Lets add-ons whose fields a static parse of the form cannot see contribute * their own unique fields. * * @since 2.12.3 * * @param array $block_ids Unique field block IDs, keyed by block ID. * A plain list of IDs is accepted too and is * normalised to this shape. * @param int $form_id Form ID. */ $filtered = apply_filters( 'srfm_unique_field_block_ids', $block_ids, $form_id ); // Normalise rather than trust: the lookup is isset( $set[ $block_id ] ), so an // add-on returning a plain list would silently disable uniqueness for the form // instead of adding to it. A non-array return keeps the derived set. return is_array( $filtered ) ? self::normalize_block_id_set( $filtered ) : $block_ids; } /** * Normalise a block-ID collection to a block ID => true map. * * Accepts both the documented map shape and a plain list of IDs. * * @param array $block_ids Block IDs as a map or a list. * * @since 2.12.3 * @return array Block IDs keyed by block ID. */ private static function normalize_block_id_set( $block_ids ) { $normalized = []; foreach ( $block_ids as $key => $value ) { // List entry: the ID is the value. Map entry: the ID is the key. $block_id = is_int( $key ) ? $value : $key; if ( is_string( $block_id ) && '' !== $block_id ) { $normalized[ $block_id ] = true; } } return $normalized; } /** * Recursively collect block IDs of blocks whose isUnique attribute is enabled. * * Recurses into innerBlocks (repeater/container children) and expands * reusable/synced patterns, mirroring Form_Styling::collect_form_block_ids(). * * Note: parse_blocks() does NOT apply block.json defaults, unlike the render path. * Every field block therefore has to keep isUnique defaulting to false — a block * that defaults it to true would be serialised without the attribute and would be * missed here while still rendering data-unique="true". * * @param array $blocks Parsed blocks from parse_blocks(). * @param array $visited_refs Reusable-block post IDs already expanded, * keyed by ID — guards against reference cycles. * * @since 2.12.3 * @return array Unique field block IDs, keyed by block ID. */ private function collect_unique_field_block_ids( $blocks, &$visited_refs = [] ) { $block_ids = []; foreach ( $blocks as $block ) { if ( ! is_array( $block ) ) { continue; } $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : []; if ( ! empty( $attrs['isUnique'] ) && ! empty( $attrs['block_id'] ) && is_scalar( $attrs['block_id'] ) ) { $block_ids[ Helper::get_string_value( $attrs['block_id'] ) ] = true; } // Reusable/synced pattern: expand the referenced wp_block post so a field // living inside a pattern is seen like an inline block. if ( isset( $block['blockName'] ) && 'core/block' === $block['blockName'] && ! empty( $attrs['ref'] ) && is_scalar( $attrs['ref'] ) ) { $ref = absint( $attrs['ref'] ); if ( $ref && ! isset( $visited_refs[ $ref ] ) ) { $visited_refs[ $ref ] = true; $ref_post = get_post( $ref ); if ( $ref_post instanceof \WP_Post && 'wp_block' === $ref_post->post_type && 'publish' === $ref_post->post_status && '' !== $ref_post->post_content ) { $block_ids += $this->collect_unique_field_block_ids( parse_blocks( $ref_post->post_content ), $visited_refs ); } } } if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { $block_ids += $this->collect_unique_field_block_ids( $block['innerBlocks'], $visited_refs ); } } return $block_ids; } /** * Check if the current request is rate-limited for unique validation. * * Uses transients keyed by IP + form ID to throttle requests. * Allows 10 requests per 60-second window per IP per form. * * @param int $form_id The form ID being validated. * @since 2.7.0 * @return bool True if rate-limited (should block), false if allowed. */ private function is_unique_validation_rate_limited( $form_id ) { return $this->is_rate_limited( 'srfm_uv_', $form_id ); } /** * Throttle a public endpoint to 10 requests per minute per IP per form. * * Shared by the uniqueness check and the client log route rather than * duplicated, so a change to the window applies to both. * * @param string $prefix Transient key prefix, unique per endpoint. * @param int $form_id The form ID the request relates to. * @since 2.12.6 * @return bool True if rate-limited (should block), false if allowed. */ private function is_rate_limited( $prefix, $form_id ) { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { return true; // Fail closed if IP cannot be determined. } $transient_key = $prefix . md5( $ip . '_' . $form_id ); $attempts = get_transient( $transient_key ); if ( false === $attempts ) { set_transient( $transient_key, 1, MINUTE_IN_SECONDS ); return false; } $attempts_count = Helper::get_integer_value( $attempts ); if ( $attempts_count >= 10 ) { return true; } set_transient( $transient_key, $attempts_count + 1, MINUTE_IN_SECONDS ); return false; } /** * Process and sanitize SureForms field data from submitted form data. * * @param array $form_data Raw form data from submission. * * @since 1.11.0 * @return array Processed and sanitized submission data. */ private function process_form_fields( $form_data ) { $form_id = isset( $form_data['form-id'] ) && is_numeric( $form_data['form-id'] ) ? absint( $form_data['form-id'] ) : 0; $submission_data = []; $form_data_keys = array_keys( $form_data ); $form_data_count = count( $form_data ); for ( $i = 0; $i < $form_data_count; $i++ ) { $key = strval( $form_data_keys[ $i ] ); /** * This will allow to pass only sureforms fields * checking -lbl- as thats mandatory for in key of sureforms fields. */ if ( false === str_contains( $key, '-lbl-' ) ) { continue; } $value = $form_data[ $key ]; $field_name = htmlspecialchars( str_replace( '_', ' ', $key ) ); $field_block_name = Helper::get_block_name_from_field( $field_name ); /** * Filters the field value during form submission processing. * * This filter allows the Pro plugin to process and modify field values before they are saved. * The Pro plugin can implement custom sanitization, validation and escaping logic for its * specialized field types. When this filter is used by Pro, the core plugin will skip its * default validation. * * @since 1.11.0 * * @param mixed $value The raw field value from form submission. * @param array $field_data Field information array containing: * - 'field_name': The field name/key * - 'field_block_name': The block type identifier * @return array { * Processed field value data * * @type bool $is_processed Whether the value was processed by Pro plugin * @type mixed $value The processed and sanitized field value * } */ $process_field_value = apply_filters( 'srfm_process_field_value', $value, [ 'field_name' => $field_name, 'field_block_name' => $field_block_name, ] ); if ( is_array( $process_field_value ) && ! empty( $process_field_value['is_processed'] ) && ! empty( $process_field_value['value'] ) ) { $submission_data[ $field_name ] = $process_field_value['value']; continue; } /** * Need to remove this refactor array value handling. * * The current array-based value handling needs to be replaced with: * 1. Block-specific value processing based on block type. * 2. Move premium features to pro version. * 3. Implement value processing through filters for extensibility. * * This will improve code organization and maintainability while properly * separating free/pro functionality. */ // If the field is an array, encode the values. This is to add support for multi-upload field. if ( is_array( $value ) ) { $submission_data[ $field_name ] = array_map( static function ( $val ) { return rawurlencode( $val ); }, $value ); } else { $submission_data[ $field_name ] = is_string( $value ) ? htmlspecialchars( $value ) : $value; } } /** * Filters the submission data before preparing it for storage. * * The second parameter is a context array containing additional metadata * about the submission. This array is extensible — new keys may be added * in future versions without changing the filter signature. * * @since 2.6.0 * * @param array $submission_data Processed form submission data. * @param array $context { * Additional context for the submission. * * @type int $form_id The ID of the form being submitted. * } */ return apply_filters( 'srfm_before_prepare_submission_data', $submission_data, [ 'form_id' => $form_id, ] ); } /** * Add From email and name in the header. * * @param array $submission_data Submission data. * @param array $item An associative array containing email settings, such as 'email_to', 'subject', 'email_body', and optional headers like 'email_reply_to', 'email_cc', and 'email_bcc'. * @param Smart_Tags $smart_tags Smart Tags instance. * @since 1.6.1 * @return string The formatted "From" email header. */ private static function add_from_data_in_header( $submission_data, $item, $smart_tags ) { $from_name = is_array( $item ) && ! empty( $item['from_name'] ) ? sanitize_text_field( Helper::get_string_value( $item['from_name'] ) ) : '{site_title}'; $from_email = is_array( $item ) && ! empty( $item['from_email'] ) ? Helper::get_string_value( $item['from_email'] ) : '{admin_email}'; // Check if the email contains smart tags. If not, validate the email. $is_valid_email = true; if ( ! str_contains( $from_email, '{' ) && ! str_contains( $from_email, '}' ) ) { $is_valid_email = filter_var( $from_email, FILTER_VALIDATE_EMAIL ); } // if the email is not valid, set it to the admin email. if ( ! $is_valid_email ) { $from_email = Helper::get_string_value( get_option( 'admin_email' ) ); } return 'From: ' . esc_html( Helper::get_string_value( $smart_tags->process_smart_tags( $from_name, $submission_data ) ) ) . ' <' . esc_html( Helper::get_string_value( $smart_tags->process_smart_tags( $from_email, $submission_data ) ) ) . '>' . "\r\n"; } }