__( 'Security check failed. Please refresh the page and try again.', 'acquisitionsuite' ) ), 403 ); } if ( ! empty( $_POST['acquis_hp'] ) ) { wp_send_json_success( array( 'download_url' => '#' ) ); } $file_id = absint( $_POST['file_id'] ?? 0 ); $form_id = absint( $_POST['form_id'] ?? 0 ); // ── Resolve full_name + email ───────────────────────────────────────── // Static form → top-level POST: full_name, email // Custom form → nested POST: acquis_fields[full_name], acquis_fields[email] // We check acquis_fields first (custom), then fall back to top-level (static). // ── Raw custom fields ───────────────────────────────────────────────── // Deep-sanitize the custom fields array — each value is text field input // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- type/emptiness check only; each value sanitized per-key below $cf_raw = ( ! empty( $_POST['acquis_fields'] ) && is_array( $_POST['acquis_fields'] ) ) ? wp_unslash( (array) $_POST['acquis_fields'] ) : array(); $cf = array(); foreach ( $cf_raw as $k => $v ) { $key = sanitize_key( (string) $k ); if ( '' === $key ) continue; if ( is_array( $v ) ) { $cf[ $key ] = array_map( 'sanitize_text_field', array_map( 'strval', $v ) ); } else { $cf[ $key ] = sanitize_text_field( (string) $v ); } } // ── Load form settings + field definitions ────────────────────────── // Used for name/email auto-detection below and required-field // validation further down; fetched once here rather than separately // in each place that needs it. $form_settings = array(); $form_fields = array(); $custom_name_key = ''; // field_key admin designated as the "name" field $custom_email_key = ''; // field_key admin designated as the "email" field if ( $form_id && class_exists( 'ACQUIS_P3_DB' ) ) { $form_obj = ACQUIS_P3_DB::get_form( $form_id ); if ( $form_obj ) { // Always decode settings — even an empty [] is valid; !empty() was // falsely blocking name_field lookup when settings array had no keys yet. $form_settings = is_array( $form_obj->settings ) ? $form_obj->settings : array(); $custom_name_key = sanitize_key( $form_settings['name_field'] ?? '' ); $custom_email_key = sanitize_key( $form_settings['email_field'] ?? '' ); } $form_fields = ACQUIS_P3_DB::get_form_fields( $form_id ); } // ── Resolve full_name ────────────────────────────────────────────── $full_name = ''; // 1. Use admin-designated name field if set if ( $custom_name_key && ! empty( $cf[ $custom_name_key ] ) ) { $full_name = sanitize_text_field( $cf[ $custom_name_key ] ); } // 2. Auto-detect common single-field names if ( empty( $full_name ) ) { foreach ( array( 'full_name', 'name', 'your_name', 'fullname' ) as $k ) { if ( ! empty( $cf[ $k ] ) ) { $full_name = sanitize_text_field( $cf[ $k ] ); break; } } } // 3. Build from first_name + last_name combo (including admin-designated combo) if ( empty( $full_name ) ) { $fn_key = ! empty( $form_settings['first_name_field'] ) ? sanitize_key( $form_settings['first_name_field'] ) : 'first_name'; $ln_key = ! empty( $form_settings['last_name_field'] ) ? sanitize_key( $form_settings['last_name_field'] ) : 'last_name'; if ( ! empty( $cf[ $fn_key ] ) ) { $full_name = sanitize_text_field( trim( $cf[ $fn_key ] . ' ' . ( $cf[ $ln_key ] ?? '' ) ) ); } } // 4. Fall back to top-level POST (static form) if ( empty( $full_name ) && ! empty( $_POST['full_name'] ) ) { $full_name = sanitize_text_field( wp_unslash( $_POST['full_name'] ) ); } // 5. If still empty on a custom form, use a placeholder so the lead still saves if ( empty( $full_name ) && $form_id > 0 ) { $full_name = __( 'Anonymous', 'acquisitionsuite' ); } // ── Resolve email ────────────────────────────────────────────────── $email = ''; // 1. Use admin-designated email field if set if ( $custom_email_key && ! empty( $cf[ $custom_email_key ] ) ) { $email = sanitize_email( $cf[ $custom_email_key ] ); } // 2. Auto-detect: scan form field definitions for field_type='email' if ( empty( $email ) ) { foreach ( $form_fields as $fd ) { if ( 'email' === $fd->field_type && ! empty( $cf[ $fd->field_key ] ) ) { $email = sanitize_email( $cf[ $fd->field_key ] ); break; } } } // 3. Auto-detect by common email field keys if ( empty( $email ) ) { foreach ( array( 'email', 'email_address', 'your_email', 'emailaddress', 'email_field' ) as $k ) { if ( ! empty( $cf[ $k ] ) ) { $email = sanitize_email( $cf[ $k ] ); break; } } } // 4. Fall back to top-level POST (static form) if ( empty( $email ) && ! empty( $_POST['email'] ) ) { $email = sanitize_email( wp_unslash( $_POST['email'] ) ); } // ── Validate ─────────────────────────────────────────────────────── $errors = array(); // For custom forms: full_name is optional (already defaulted to Anonymous above) // For static form (form_id=0): full_name is required if ( $form_id === 0 && ( empty( $full_name ) || mb_strlen( $full_name ) < 2 ) ) { $errors[] = __( 'Please enter your full name (at least 2 characters).', 'acquisitionsuite' ); } if ( mb_strlen( $full_name ) > 200 ) $errors[] = __( 'Name is too long.', 'acquisitionsuite' ); if ( empty( $email ) || ! is_email( $email ) ) $errors[] = __( 'Please enter a valid email address.', 'acquisitionsuite' ); // A required custom field (radio/checkbox especially) is only // enforced by client-side JS in the modal — that's advisory, not a // real guarantee, since anyone can POST directly to this endpoint // and skip it entirely. Re-check every required field server-side // too, the same way full_name/email already are above. foreach ( $form_fields as $fd ) { if ( empty( $fd->required ) ) continue; $val = $cf[ $fd->field_key ] ?? ''; $filled = is_array( $val ) ? (bool) array_filter( $val, 'strlen' ) : ( '' !== trim( (string) $val ) ); if ( ! $filled ) { /* translators: %s: the required field's label */ $errors[] = sprintf( __( '%s is required.', 'acquisitionsuite' ), $fd->label ?: $fd->field_key ); } } if ( ! empty( $errors ) ) { wp_send_json_error( array( 'message' => implode( ' ', $errors ) ), 422 ); } // file_id is only required when this submission is actually gating a // file (the download shortcode always sends a real file_id). A // standalone form (appointment/registration/etc, no file attached) // legitimately submits with file_id=0 mirrors the existing // tolerance for form_id=0 (the static form) by the same logic. $file = self::empty_file_shape(); if ( $file_id ) { $real_file = ACQUIS_File_Manager::get_file_data( $file_id ); $is_url = $real_file && 'url' === ( $real_file['source_type'] ?? 'upload' ); if ( ! $real_file || ( ! $is_url && ! file_exists( $real_file['path'] ) ) || ( $is_url && empty( $real_file['url'] ) ) ) { wp_send_json_error( array( 'message' => __( 'The requested file was not found.', 'acquisitionsuite' ) ), 404 ); } $file = $real_file; } if ( $this->is_rate_limited() ) { wp_send_json_error( array( 'message' => __( 'Too many requests. Please wait a few minutes and try again.', 'acquisitionsuite' ) ), 429 ); } // Find existing lead by email or create a new one deduplication // prevents the same person submitting twice and appearing as two // separate leads. A new submission record is still written below so // the full multi-submission history is always preserved; only the // top-level lead row is deduplicated, matching the payment flow. $existing_lead = ACQUIS_Database::get_lead_by_email( $email ); if ( $existing_lead ) { $lead_id = (int) $existing_lead->id; global $wpdb; // Update the lead's most-recent file, visit count, and last-seen // timestamp. Intelligence data (country/device/etc.) is refreshed // below when $intel is gathered so the record stays current. $wpdb->update( ACQUIS_Database::leads_table(), array( 'file_id' => absint( $file_id ), 'visit_count' => (int) $existing_lead->visit_count + 1, 'last_seen_at' => current_time( 'mysql' ), ), array( 'id' => $lead_id ) ); } else { $lead_id = ACQUIS_Database::insert_lead( $full_name, $email, $file_id ); if ( ! $lead_id ) { wp_send_json_error( array( 'message' => __( 'Something went wrong saving your details. Please try again.', 'acquisitionsuite' ) ), 500 ); } } // ── Phase 4: Lead intelligence (device/browser/OS + geo) ─────────── $client_ip = $this->get_client_ip(); $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; $is_repeat_visit = class_exists( 'ACQUIS_Intelligence' ) && ACQUIS_Intelligence::is_repeat_visit(); $intel = array(); if ( class_exists( 'ACQUIS_Intelligence' ) ) { $intel = ACQUIS_Intelligence::gather( $client_ip, $user_agent ); global $wpdb; $wpdb->update( ACQUIS_Database::leads_table(), array( 'user_agent' => $user_agent, 'device' => $intel['device'], 'browser' => $intel['browser'], 'os' => $intel['os'], 'country' => $intel['country'], 'region' => $intel['region'], 'city' => $intel['city'], ), array( 'id' => $lead_id ) ); } // ── Phase 4: Place the new lead on the first pipeline stage ──────── if ( class_exists( 'ACQUIS_Pipeline' ) ) { ACQUIS_Pipeline::place_new_lead( $lead_id ); } // ── Phase 4: identify this browser as belonging to this lead, so // future page visits can be tracked against it (page_visit trigger). if ( ! headers_sent() ) { setcookie( 'acquis_lead', (string) $lead_id, array( 'expires' => time() + YEAR_IN_SECONDS, 'path' => '/', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax', ) ); } $delivery_mode = get_option( 'acquis_delivery_mode', 'both' ); $show_direct = $file_id && in_array( $delivery_mode, array( 'instant', 'both' ), true ); $send_email = in_array( $delivery_mode, array( 'email', 'both' ), true ); // No file attached (standalone form) — nothing to gate, so no // download token is ever issued; the success response below omits // download_url entirely and just confirms the submission. $download_url = ''; $email_dl_url = ''; if ( $file_id ) { // When both modes are active we need TWO independent tokens so that // clicking the direct download (which marks its token used=1) does not // invalidate the link sent by email, and vice-versa. $token = ACQUIS_Database::create_token( $lead_id, $file_id ); if ( ! $token ) { wp_send_json_error( array( 'message' => __( 'Could not generate download link. Please try again.', 'acquisitionsuite' ) ), 500 ); } $download_url = add_query_arg( array( 'acquis_download' => '1', 'token' => $token, ), home_url( '/' ) ); // For the email link: create a separate token so each link is independent. // If email is not being sent, reuse the same token (no waste). if ( $show_direct && $send_email ) { $email_token = ACQUIS_Database::create_token( $lead_id, $file_id ); $email_dl_url = $email_token ? add_query_arg( array( 'acquis_download' => '1', 'token' => $email_token ), home_url( '/' ) ) : $download_url; } else { $email_dl_url = $download_url; } } // ── Phase 3: Form submission tracking ───────────────────────────── $source_url = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : ''; $sub_meta = array( 'source_url' => $source_url, 'source_post' => url_to_postid( $source_url ), 'referrer' => $source_url, 'utm_source' => sanitize_text_field( wp_unslash( $_POST['utm_source'] ?? '' ) ), 'utm_medium' => sanitize_text_field( wp_unslash( $_POST['utm_medium'] ?? '' ) ), 'utm_campaign' => sanitize_text_field( wp_unslash( $_POST['utm_campaign'] ?? '' ) ), 'ip_address' => $this->get_client_ip(), ); // Collect custom field values from the submitted form $custom_fields = array(); if ( !empty( $_POST['acquis_fields'] ) && is_array( $_POST['acquis_fields'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash foreach ( isset( $_POST['acquis_fields'] ) ? wp_unslash( (array) $_POST['acquis_fields'] ) : array() as $key => $val ) { $clean_key = sanitize_key( $key ); $clean_val = is_array( $val ) ? sanitize_text_field( implode( ', ', array_map( 'sanitize_text_field', $val ) ) ) : sanitize_text_field( $val ); $custom_fields[ $clean_key ] = $clean_val; } } // Create submission record + save field values if ( class_exists( 'ACQUIS_P3_DB' ) ) { $sub_id = ACQUIS_P3_DB::create_submission( $form_id, $lead_id, $file_id, $sub_meta ); if ( $sub_id ) { // For custom forms: save whatever was in acquis_fields[] // For the default static form: save full_name + email explicitly // so the submission row always has meaningful content if ( !empty( $custom_fields ) ) { $fields_def = $form_id ? ACQUIS_P3_DB::get_form_fields( $form_id ) : array(); $label_map = array(); foreach ( $fields_def as $fd ) { $label_map[ $fd->field_key ] = $fd->label; } $values_payload = array(); foreach ( $custom_fields as $k => $v ) { $values_payload[ $k ] = array( 'label' => $label_map[ $k ] ?? $k, 'value' => $v ); } ACQUIS_P3_DB::save_submission_values( $sub_id, $values_payload ); } else { // Static default form save the two fields it always captures, // or any form where no custom fields were in the POST ACQUIS_P3_DB::save_submission_values( $sub_id, array( 'full_name' => array( 'label' => 'Full Name', 'value' => $full_name ), 'email' => array( 'label' => 'Email', 'value' => $email ), ) ); } } else { // Log the failure so it appears in WP debug log if ( defined('WP_DEBUG') && WP_DEBUG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( sprintf( 'AcquisitionSuite: create_submission failed for lead_id=%d form_id=%d file_id=%d cf_count=%d wpdb_error=%s', $lead_id, $form_id, $file_id, count($custom_fields), $GLOBALS['wpdb']->last_error ) ); } } // Track analytics event ACQUIS_P3_DB::track_event( 'form_submit', array_merge( $sub_meta, array( 'form_id'=>$form_id, 'file_id'=>$file_id, 'lead_id'=>$lead_id ) ) ); } // A Lead Funnel can say what happens after this form is submitted (a message, a redirect, a notification). // It is looked up here, not stored on the form, so a funnel can use an existing form without changing it. $funnel_rt = ( $form_id && class_exists( 'ACQUIS_Funnel_Builder' ) ) ? ACQUIS_Funnel_Builder::runtime( $form_id ) : null; // Schedule via shutdown action (non-blocking for the visitor) add_action( 'shutdown', function() use ( $lead_id, $email, $full_name, $download_url, $email_dl_url, $file, $file_id, $custom_fields, $form_id, $is_repeat_visit, $intel, $existing_lead, $funnel_rt ) { // "Notify me" for a funnel: one plain email to the owner, whatever else is or is not installed. if ( $funnel_rt && ! empty( $funnel_rt['notify']['enabled'] ) && is_email( $funnel_rt['notify']['email'] ?? '' ) ) { ACQUIS_Lead_Handler::notify_owner( $funnel_rt, $lead_id, $full_name, $email, $custom_fields, $file ); } // Integration manager (email engine, CRMs) uses $email_dl_url — a separate // token from the direct download URL — so both links remain valid independently. ACQUIS_Integration_Manager::dispatch( $lead_id, $email, $full_name, $email_dl_url, $file, $form_id, $custom_fields ); // Auto-enroll into form-triggered sequences FIRST before scoring // so that if a sequence welcome email is delayed 0 minutes, it // still runs this cron cycle with the correct lead context. // Deliberately outside the ACQUIS_Lead_Scorer block below so sequence // enrollment is never skipped due to a missing scorer class. if ( class_exists( 'ACQUIS_Sequences' ) ) { $form_seqs = ACQUIS_Sequences::get_form_triggered_sequences( $form_id ); foreach ( $form_seqs as $seq ) { if ( class_exists( 'ACQUIS_Sequences' ) ) ACQUIS_Sequences::enroll( $lead_id, $seq->id ); } } // Phase 3: score the lead if ( class_exists( 'ACQUIS_Lead_Scorer' ) ) { $context_fields = array_merge( $custom_fields, array( 'file_id' => $file_id, 'form_id' => $form_id, 'country' => $intel['country'] ?? '', 'region' => $intel['region'] ?? '', 'city' => $intel['city'] ?? '', 'device' => $intel['device'] ?? '', 'browser' => $intel['browser'] ?? '', 'os' => $intel['os'] ?? '', ) ); $score = class_exists( 'ACQUIS_Lead_Scorer' ) ? ACQUIS_Lead_Scorer::score( $lead_id, $file_id, $context_fields ) : 0; if ( class_exists( 'ACQUIS_Lead_Scorer' ) ) ACQUIS_Lead_Scorer::add_event_points( $lead_id, 'form_submit' ); $context_full = array_merge( $context_fields, array( 'full_name' => $full_name, 'email' => $email, 'file_name' => $file['title'] ?? '', 'lead_score' => class_exists( 'ACQUIS_Lead_Scorer' ) ? ACQUIS_Lead_Scorer::current_total( $lead_id ) : 0, ) ); if ( class_exists( 'ACQUIS_Automation_Engine' ) ) ACQUIS_Automation_Engine::run( 'form_submit', $lead_id, $context_full ); // Re-engagement signal fire exactly ONCE per submission for // returning leads, using $existing_lead (DB-based) as the // authoritative signal. $is_repeat_visit (cookie-based) can // also be true for new leads on their second browser visit, // so we unify to the DB signal to avoid double-scoring. $is_reengagement = $existing_lead || $is_repeat_visit; if ( $is_reengagement ) { if ( class_exists( 'ACQUIS_Lead_Scorer' ) ) ACQUIS_Lead_Scorer::add_event_points( $lead_id, 'repeat_visit' ); if ( class_exists( 'ACQUIS_Intelligence' ) ) { ACQUIS_Intelligence::record_repeat_visit( $lead_id ); } $context_full['lead_score'] = class_exists( 'ACQUIS_Lead_Scorer' ) ? ACQUIS_Lead_Scorer::current_total( $lead_id ) : 0; if ( class_exists( 'ACQUIS_Automation_Engine' ) ) ACQUIS_Automation_Engine::run( 'repeat_visit', $lead_id, array_merge( $context_full, array( 'visit_count' => $existing_lead ? (int) $existing_lead->visit_count + 1 : 1, ) ) ); } } } ); // Build response — direct download uses its own token (separate from email token). // Standalone forms (no file_id) have no download at all; use the // global standalone-forms success message instead, since "Your // download is ready!" doesn't fit an appointment/registration. $success_message = $file_id ? get_option( 'acquis_success_message', 'Your download is ready!' ) : get_option( 'acquis_success_message_standalone', __( 'Thank you! Your submission has been received.', 'acquisitionsuite' ) ); // What the funnel behind this form says: its own message, or a page to send the visitor to. $redirect_url = ''; if ( $funnel_rt ) { $after = (array) ( $funnel_rt['after'] ?? array() ); if ( 'redirect' === ( $after['type'] ?? '' ) && ! empty( $after['url'] ) ) { $redirect_url = esc_url_raw( $after['url'], array( 'http', 'https' ) ); } elseif ( ! empty( $after['message'] ) ) { $success_message = $after['message']; } } wp_send_json_success( array( 'download_url' => $show_direct ? esc_url_raw( $download_url ) : '', 'delivery_mode' => $delivery_mode, 'success_message' => esc_html( $success_message ), 'file_name' => esc_html( $file['title'] ), 'redirect_url' => $redirect_url, ) ); } /** * Tell the site owner a lead arrived through a funnel. Plain text, one email, to the address the funnel names. * * @param array $funnel_rt From ACQUIS_Funnel_Builder::runtime(). * @param array $custom_fields field_key => submitted value. * @param array $file File data, or the empty shape. */ public static function notify_owner( array $funnel_rt, $lead_id, $full_name, $email, array $custom_fields, $file ) { $to = sanitize_email( $funnel_rt['notify']['email'] ?? '' ); $name = trim( preg_replace( '/[\r\n]+/', ' ', wp_strip_all_tags( (string) $full_name ) ) ); $funnel = trim( preg_replace( '/[\r\n]+/', ' ', wp_strip_all_tags( (string) ( $funnel_rt['name'] ?? '' ) ) ) ); /* translators: 1: lead name, 2: funnel name */ $subject = sprintf( __( 'New lead: %1$s (%2$s)', 'acquisitionsuite' ), '' !== $name ? $name : $email, $funnel ); $lines = array( /* translators: %s: funnel name */ sprintf( __( 'Someone just signed up through your funnel "%s".', 'acquisitionsuite' ), $funnel ), '', __( 'Name:', 'acquisitionsuite' ) . ' ' . $name, __( 'Email:', 'acquisitionsuite' ) . ' ' . $email, ); foreach ( $custom_fields as $k => $v ) { if ( in_array( $k, array( 'full_name', 'email', 'first_name', 'last_name', 'name' ), true ) || '' === trim( (string) $v ) ) { continue; } $lines[] = ucwords( str_replace( '_', ' ', (string) $k ) ) . ': ' . wp_strip_all_tags( (string) $v ); } if ( ! empty( $file['title'] ) ) { $lines[] = __( 'Resource:', 'acquisitionsuite' ) . ' ' . $file['title']; } $lines[] = ''; $lines[] = __( 'See all leads:', 'acquisitionsuite' ) . ' ' . admin_url( 'admin.php?page=acquis-submissions' ); wp_mail( $to, $subject, implode( "\n", $lines ) ); } /** * The same shape ACQUIS_File_Manager::get_file_data() returns, all * empty used when a submission has no file attached (standalone form) * so downstream code (email engine, ESP integrations, automation * context) can keep assuming $file is always an array, never false. * * @return array */ private static function empty_file_shape() { return array( 'id' => 0, 'title' => '', 'source_type' => 'upload', 'url' => '', 'path' => '', 'name' => '', 'ext' => '', 'size' => '', 'mime_type' => '', ); } // ── Admin AJAX Handlers ──────────────────────────────────────────────── public function handle_test_email() { check_ajax_referer( 'acquis_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( array( 'message' => 'Unauthorized' ), 403 ); $to = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : get_bloginfo( 'admin_email' ); $sent = ACQUIS_Email_Engine::send_test( $to ); if ( $sent ) { wp_send_json_success( array( 'message' => "Test email sent to {$to}." ) ); } else { wp_send_json_error( array( 'message' => 'wp_mail() failed. Please check your SMTP settings.' ) ); } } public function fetch_mailchimp_lists() { check_ajax_referer( 'acquis_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( null, 403 ); $api_key = sanitize_text_field( wp_unslash( $_POST['api_key'] ?? '' ) ); $lists = ACQUIS_Mailchimp::get_lists( $api_key ); wp_send_json_success( array( 'lists' => $lists ) ); } public function fetch_mailwizz_lists() { check_ajax_referer( 'acquis_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( null, 403 ); $url = sanitize_url( wp_unslash( $_POST['api_url'] ?? '' ) ); $pub = sanitize_text_field( wp_unslash( $_POST['public_key'] ?? '' ) ); $priv = sanitize_text_field( wp_unslash( $_POST['private_key'] ?? '' ) ); $lists = ACQUIS_Mailwizz::get_lists( $url, $pub, $priv ); wp_send_json_success( array( 'lists' => $lists ) ); } public function test_mailwizz_connection() { check_ajax_referer( 'acquis_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( null, 403 ); $url = sanitize_url( wp_unslash( $_POST['api_url'] ?? '' ) ); $pub = sanitize_text_field( wp_unslash( $_POST['public_key'] ?? '' ) ); $priv = sanitize_text_field( wp_unslash( $_POST['private_key'] ?? '' ) ); $result = ACQUIS_Mailwizz::test_connection( $url, $pub, $priv ); if ( $result['success'] ) { wp_send_json_success( array( 'message' => $result['message'] ) ); } else { wp_send_json_error( array( 'message' => $result['message'] ) ); } } public function fetch_brevo_lists() { check_ajax_referer( 'acquis_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( null, 403 ); $api_key = sanitize_text_field( wp_unslash( $_POST['api_key'] ?? '' ) ); $lists = ACQUIS_Brevo::get_lists( $api_key ); wp_send_json_success( array( 'lists' => $lists ) ); } public function test_brevo_connection() { check_ajax_referer( 'acquis_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( null, 403 ); $api_key = sanitize_text_field( wp_unslash( $_POST['api_key'] ?? '' ) ); $result = ACQUIS_Brevo::test_connection( $api_key ); if ( $result['success'] ) { wp_send_json_success( array( 'message' => $result['message'] ) ); } else { wp_send_json_error( array( 'message' => $result['message'] ) ); } } /** * Rate limit form submissions. * * Uses REMOTE_ADDR as the primary identifier — this is the actual TCP peer * and cannot be spoofed by the client. Proxy headers (X-Forwarded-For, * CF-Connecting-IP) are intentionally ignored here because they can be * injected by any client to bypass throttling (e.g. set X-Forwarded-For: * 1.2.3.4 to get a fresh transient key every request). * * Transient key is bound to the site URL so multisite installs don't share * counters, and is hashed so raw IPs are never stored as option names. * * @return bool True if the caller has exceeded the limit. */ private function is_rate_limited() { $ip = $this->get_client_ip(); // Bind key to site so multisite installs are isolated. // Max 5 submissions per IP per 10 minutes. $key = 'acquis_rl_' . md5( get_option( 'siteurl' ) . $ip ); // Enforce maximum transient key length safe for WordPress options table. if ( strlen( $key ) > 172 ) { $key = 'acquis_rl_' . hash( 'sha256', get_option( 'siteurl' ) . $ip ); } $count = (int) get_transient( $key ); if ( $count >= 5 ) { return true; } set_transient( $key, $count + 1, 10 * MINUTE_IN_SECONDS ); return false; } /** * Get the real client IP using only REMOTE_ADDR. * * We deliberately do NOT trust X-Forwarded-For or similar proxy headers * in the rate limiter. Those headers are set by the client and can be * trivially spoofed to produce a unique IP on every request, bypassing any * transient-based throttle. REMOTE_ADDR is the actual TCP source address * as seen by the web server and cannot be forged. * * @return string Validated IPv4 or IPv6 address, or '0.0.0.0' as fallback. */ private function get_client_ip() { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash $raw = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : ''; $ip = filter_var( trim( $raw ), FILTER_VALIDATE_IP ); return $ip ? $ip : '0.0.0.0'; } }