get( 'form_settings' ); $form_id = isset( $form_settings['id'] ) ? $form_settings['id'] : 'unknown'; $form_name = isset( $form_settings['form_name'] ) ? $form_settings['form_name'] : 'Contact Form'; $raw_fields = $record->get( 'fields' ); $fields = array(); foreach ( $raw_fields as $field_id => $field_data ) { $fields[ $field_id ] = isset( $field_data['value'] ) ? $field_data['value'] : ''; } $referrer = isset( $_SERVER['HTTP_REFERER'] ) ? sanitize_url( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : ''; $payload = array( 'action' => 'submit', 'form_id' => $form_id, 'form_name' => $form_name, 'fields' => $fields, 'source_url' => $referrer, 'source_ip' => a1tools_get_client_ip(), 'crm_company_id' => (int) get_option( 'a1tools_form_crm_company_id', 0 ), 'crm_franchise_id' => (int) get_option( 'a1tools_form_crm_franchise_id', 0 ), ); /** * Filter the A1 Tools form submission API URL. * * @since 1.5.0 * @param string $api_url The API URL. */ $api_url = apply_filters( 'a1tools_form_api_url', A1TOOLS_FORM_API_URL . '?action=submit' ); // Defer the API call to PHP shutdown so the browser receives Elementor's // AJAX response (thank-you page) before we make the outbound request. // Under PHP-FPM we use fastcgi_finish_request() to flush early so the // blocking call below cannot delay the visitor's response. Capturing the // response (blocking => true) lets us record real outcomes for diagnostics. add_action( 'shutdown', function () use ( $api_url, $payload, $form_secret, $form_name ) { if ( function_exists( 'fastcgi_finish_request' ) ) { @fastcgi_finish_request(); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort flush } a1tools_send_form_payload( $api_url, $payload, $form_secret, $form_name ); } ); } // Only register the hook when forwarding is enabled. Prevents interference // with other Elementor form actions (MailChimp, email, etc.). if ( get_option( 'a1tools_form_enabled', false ) ) { add_action( 'elementor_pro/forms/new_record', 'a1tools_handle_elementor_form_submission', 99, 2 ); } /** * Send a signed form submission payload to the A1 Tools API and record the outcome. * * The signing scheme is HMAC-SHA256 over `body . timestamp` keyed with the * shared secret. The server validates `X-Form-Secret`, `X-Form-Timestamp` * (5-minute window), and `X-Form-Signature`. * * @since 2.4.0 * @param string $api_url Submission endpoint. * @param array $payload Payload (already structured for the server). * @param string $form_secret Shared HMAC secret. * @param string $form_name Form name (for the outcome label). * @return array{success:bool, code:int, message:string} Outcome. */ function a1tools_send_form_payload( $api_url, array $payload, $form_secret, $form_name ) { $body = wp_json_encode( $payload ); $timestamp = (string) time(); $signature = hash_hmac( 'sha256', $body . $timestamp, $form_secret ); $response = wp_remote_post( $api_url, array( 'timeout' => 15, 'httpversion' => '1.1', 'blocking' => true, 'headers' => array( 'Content-Type' => 'application/json', 'X-Form-Secret' => $form_secret, 'X-Form-Timestamp' => $timestamp, 'X-Form-Signature' => $signature, ), 'body' => $body, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { $message = $response->get_error_message(); a1tools_record_form_outcome( false, $form_name, $message ); return array( 'success' => false, 'code' => 0, 'message' => $message, ); } $code = (int) wp_remote_retrieve_response_code( $response ); $body_decoded = json_decode( wp_remote_retrieve_body( $response ), true ); $ok = ( $code >= 200 && $code < 300 ) && is_array( $body_decoded ) && ! empty( $body_decoded['success'] ); if ( $ok ) { $status = isset( $body_decoded['status'] ) ? $body_decoded['status'] : 'created'; $item_id = isset( $body_decoded['item_id'] ) ? (int) $body_decoded['item_id'] : 0; $message = $item_id ? sprintf( /* translators: 1: status, 2: item id */ __( '%1$s (Sunday item #%2$d)', 'a1-tools' ), $status, $item_id ) : ucfirst( $status ); a1tools_record_form_outcome( true, $form_name, $message ); } else { $err_msg = is_array( $body_decoded ) && isset( $body_decoded['error'] ) ? $body_decoded['error'] : sprintf( /* translators: %d: HTTP code */ __( 'HTTP %d', 'a1-tools' ), $code ); a1tools_record_form_outcome( false, $form_name, $err_msg ); $ok = false; } return array( 'success' => $ok, 'code' => $code, 'message' => isset( $message ) ? $message : ( isset( $err_msg ) ? $err_msg : '' ), ); } /** * Persist the most recent form submission outcome for the admin diagnostics panel. * * @since 2.4.0 * @param bool $success Whether the submission succeeded. * @param string $form_name Human-readable form name. * @param string $message Outcome message (success label or error reason). * @return void */ function a1tools_record_form_outcome( $success, $form_name, $message ) { update_option( 'a1tools_form_last_outcome', array( 'success' => (bool) $success, 'form_name' => sanitize_text_field( (string) $form_name ), 'message' => sanitize_text_field( (string) $message ), 'timestamp' => time(), ), false ); } // ============================================================================ // AJAX: TEST CONNECTION // ============================================================================ /** * Handle the "Test Connection" admin AJAX request. * * Calls the server's validate_config endpoint with the configured secret and * CRM IDs, then returns a structured pass/fail report so the admin can see * which check succeeded or failed without enabling WP_DEBUG. * * @since 2.4.0 * @return void */ function a1tools_ajax_test_form_connection() { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'a1-tools' ) ), 403 ); } check_ajax_referer( 'a1tools_test_form_conn' ); $form_secret = get_option( 'a1tools_form_secret', '' ); if ( empty( $form_secret ) ) { wp_send_json_error( array( 'message' => __( 'No form secret configured. Generate one and save settings first.', 'a1-tools' ) ) ); } $payload = array( 'crm_company_id' => (int) get_option( 'a1tools_form_crm_company_id', 0 ), 'crm_franchise_id' => (int) get_option( 'a1tools_form_crm_franchise_id', 0 ), ); $body = wp_json_encode( $payload ); $timestamp = (string) time(); $signature = hash_hmac( 'sha256', $body . $timestamp, $form_secret ); $api_url = apply_filters( 'a1tools_form_api_url', A1TOOLS_FORM_API_URL . '?action=validate_config' ); // Strip the action param if the filter overrode it; we set our own. $api_url = add_query_arg( 'action', 'validate_config', remove_query_arg( 'action', $api_url ) ); $response = wp_remote_post( $api_url, array( 'timeout' => 15, 'httpversion' => '1.1', 'blocking' => true, 'headers' => array( 'Content-Type' => 'application/json', 'X-Form-Secret' => $form_secret, 'X-Form-Timestamp' => $timestamp, 'X-Form-Signature' => $signature, ), 'body' => $body, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { wp_send_json_error( array( 'message' => __( 'Network error: ', 'a1-tools' ) . $response->get_error_message() ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); $body_decoded = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $body_decoded ) ) { wp_send_json_error( array( 'message' => sprintf( /* translators: %d: HTTP code */ __( 'Invalid server response (HTTP %d).', 'a1-tools' ), $code ) ) ); } if ( empty( $body_decoded['success'] ) ) { $err = isset( $body_decoded['error'] ) ? $body_decoded['error'] : __( 'Unknown server error.', 'a1-tools' ); wp_send_json_error( array( 'message' => $err ) ); } $server_checks = isset( $body_decoded['checks'] ) && is_array( $body_decoded['checks'] ) ? $body_decoded['checks'] : array(); $company_id = (int) get_option( 'a1tools_form_crm_company_id', 0 ); $franchise_id = (int) get_option( 'a1tools_form_crm_franchise_id', 0 ); $checks = array( 'secret' => array( 'ok' => true, 'label' => __( 'Form secret accepted by server', 'a1-tools' ), ), ); if ( $company_id > 0 ) { $company_ok = ! empty( $server_checks['company_exists'] ); $checks['company'] = array( 'ok' => $company_ok, 'label' => $company_ok ? sprintf( /* translators: %d: company id */ __( 'CRM Company #%d found', 'a1-tools' ), $company_id ) : sprintf( /* translators: %d: company id */ __( 'CRM Company #%d NOT found', 'a1-tools' ), $company_id ), ); } else { $checks['company'] = array( 'ok' => true, 'label' => __( 'CRM lead creation disabled (Company ID = 0)', 'a1-tools' ), ); } if ( $franchise_id > 0 ) { $franchise_ok = ! empty( $server_checks['franchise_valid'] ); $checks['franchise'] = array( 'ok' => $franchise_ok, 'label' => $franchise_ok ? sprintf( /* translators: %d: franchise id */ __( 'CRM Franchise #%d belongs to the configured Company', 'a1-tools' ), $franchise_id ) : sprintf( /* translators: %d: franchise id */ __( 'CRM Franchise #%d does NOT belong to the configured Company', 'a1-tools' ), $franchise_id ), ); } else { $checks['franchise'] = array( 'ok' => true, 'label' => __( 'No franchise assignment (Franchise ID = 0)', 'a1-tools' ), ); } wp_send_json_success( array( 'checks' => $checks ) ); } add_action( 'wp_ajax_a1tools_test_form_connection', 'a1tools_ajax_test_form_connection' ); // ============================================================================ // HELPERS // ============================================================================ /** * Get client IP address, handling proxies. * * @since 1.5.0 * @return string Client IP. */ function a1tools_get_client_ip() { if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { $ips = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) ); return trim( $ips[0] ); } if ( ! empty( $_SERVER['HTTP_X_REAL_IP'] ) ) { return sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_REAL_IP'] ) ); } return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0'; }