miniOrange OTP Login, Verification and SMS Notifications <= 5.5.1 - Authentication Bypass to Administrator Account Takeover via 'username_b' Parameter
Description
The miniOrange OTP Login, Verification and SMS Notifications plugin for WordPress is vulnerable to Authentication Bypass leading to Administrator Account Takeover in all versions up to, and including, 5.5.1. This is due to the `um_reset_password_process_hook()` function performing no server-side verification that the OTP validation step was completed, and relying solely on a public `form_nonce` nonce that the plugin itself emits to unauthenticated visitors via the `moumprvar` JavaScript object on the Ultimate Member password reset page, while still accepting the attacker-controlled `username_b` parameter to target any WordPress user without role restriction or any binding to a previously validated OTP session. This makes it possible for unauthenticated attackers to obtain a freshly generated password-reset URL for an arbitrary Administrator account — returned in a 302 `Location` header — and use it to take full control of that account. Exploitation requires the Ultimate Member Password Reset Form integration to be active and the plugin to not be configured for phone-only reset.
CVSS Vector Breakdown
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:HTechnical Details
<=5.5.1What Changed in the Fix
Changes introduced in v5.5.2
Source Code
WordPress.org SVN# Exploitation Research Plan - CVE-2026-14245 ## 1. Vulnerability Summary The **miniOrange OTP Login, Verification and SMS Notifications** plugin (<= 5.5.1) contains a critical authentication bypass vulnerability within its integration with the **Ultimate Member (UM)** plugin. Specifically, the fun…
Show full research plan
Exploitation Research Plan - CVE-2026-14245
1. Vulnerability Summary
The miniOrange OTP Login, Verification and SMS Notifications plugin (<= 5.5.1) contains a critical authentication bypass vulnerability within its integration with the Ultimate Member (UM) plugin. Specifically, the function um_reset_password_process_hook() fails to verify that a one-time password (OTP) has been successfully validated before proceeding with the password reset logic.
The plugin relies on a publicly accessible nonce (form_nonce) exposed in the frontend JavaScript object moumprvar. An attacker can provide an arbitrary target username via the username_b parameter. Because there is no server-side check linking the session to a validated OTP, the plugin generates a password reset link for the targeted user (including Administrators) and returns it in a 302 Redirect's Location header.
2. Attack Vector Analysis
- Vulnerable Endpoint: The Ultimate Member Password Reset page (typically
/password-reset/or any page containing the[ultimatemember_password_reset]shortcode). - Vulnerable Function:
um_reset_password_process_hook()(inferred from description). - Payload Parameter:
username_b(the target username) andform_nonce(the public nonce). - Authentication Level: Unauthenticated (PR:N).
- Preconditions:
- Ultimate Member plugin must be active.
- The "Ultimate Member Password Reset Form" integration must be enabled in miniOrange settings.
- The plugin must NOT be in "phone-only" reset mode.
3. Code Flow
- Entry Point: An unauthenticated visitor accesses the Ultimate Member password reset page.
- Nonce Exposure: The plugin enqueues a script that localizes the
moumprvarobject, containing aform_nonce. - Request Submission: The attacker sends a POST request to the password reset page.
- Vulnerable Hook: The
um_reset_password_process_hook()is triggered by Ultimate Member's form processing logic. - Bypass: The function checks if
form_nonceis present and valid (which it is, as it's public). It then looks for theusername_bparameter. - SINK: The function generates a password reset key for the user specified in
username_bviaretrieve_password()or UM's equivalent and initiates a redirect to the reset URL. - Exfiltration: The reset URL, containing the secret key, is sent back to the attacker in the
Locationheader.
4. Nonce Acquisition Strategy
The nonce is required for the um_reset_password_process_hook() to execute its logic.
- Identify Page: Find the page with the Ultimate Member reset shortcode.
- Navigation: Use
browser_navigateto load that page. - Extraction: Use
browser_evalto extract the nonce from themoumprvarglobal object.
Javascript to execute:
window.moumprvar?.form_nonce
5. Exploitation Strategy
The goal is to trigger the bypass and capture the Location header containing the reset link.
Step-by-Step Execution
- Identify Target: Choose an administrator username (e.g.,
admin). - Get Nonce: Navigate to the UM Password Reset page and extract
moumprvar.form_nonce. - Craft Request: Perform a POST request to the reset page URL.
- Capture Redirect: Ensure the
http_requesttool does not automatically follow redirects (or inspect the first response in the chain) to grab theLocationheader.
Exploit Request:
- Method:
POST - URL:
http://<target-site>/password-reset/(or the identified UM reset slug) - Headers:
Content-Type: application/x-www-form-urlencoded - Body:
Note:username_b=admin&form_nonce=<EXTRACTED_NONCE>&user_login=admin&um_for_reset_password=1&_wpnonce=<UM_NONCE_IF_REQUIRED>user_loginis often required by Ultimate Member itself to initiate the flow, whileusername_bis used by the miniOrange bypass.
6. Test Data Setup
- Users:
- Create an admin user:
wp user create victim_admin victim@example.com --role=administrator --user_pass=password123
- Create an admin user:
- Ultimate Member Setup:
- Install and activate Ultimate Member.
- Create a Password Reset page using the UM shortcode:
wp post create --post_type=page --post_title="Password Reset" --post_status=publish --post_content='[ultimatemember_password_reset]'
- miniOrange Configuration:
- Activate
miniorange-otp-verification. - (Via
wp-cliorbrowser_evalon the settings page): Enable the "Ultimate Member Password Reset Form" integration.
- Activate
7. Expected Results
- The server responds with a
302 Found. - The
Locationheader contains a URL similar to:http://<target-site>/password-reset/?rp_key=REDACTED_SECRET_KEY&rp_login=victim_admin - Navigating to this URL allows the attacker to set a new password without knowing the old one or providing an OTP.
8. Verification Steps
- Check Redirection: Verify the
Locationheader containsrp_key. - Verify Password Change:
- Use the captured URL to change the password via the browser.
- Verify the admin can no longer login with the old password:
wp user check-password victim_admin password123(Should return non-zero/fail). - Verify the new password works.
9. Alternative Approaches
- Parameter variations: If
username_bis ignored, check if the plugin acceptsuser_logindirectly within theum_reset_password_process_hooklogic ifform_nonceis present. - Direct Hook Trigger: If the frontend page is complex, attempt to trigger the hook via
admin-ajax.phpif UM allows password reset actions via AJAX, providing the sameusername_bandform_nonceparameters. - Check for Phone-Only: If the exploit fails, ensure the plugin setting
mo_otp_um_password_reset_phone_onlyis not set to1, as this might change the expected parameters to a phone number.
Summary
The miniOrange OTP Login, Verification and SMS Notifications plugin is vulnerable to authentication bypass via its Ultimate Member integration. Unauthenticated attackers can exploit a logic flaw in the password reset process where the plugin fails to verify that an OTP was successfully validated, allowing them to obtain a password reset link for any user, including administrators.
Security Fix
@@ -1,1008 +1,1008 @@ -<?php -/** - * OTP Spam AJAX Handler - * - * @package otpspampreventer/handler - */ - -namespace OSP\Handler; - -use OSP\Handler\MoOtpSpamStorage; -use OSP\Handler\MoOtpSpamPreventerHandler; -use OSP\Helper\MoPuzzleHelper; -use OSP\Helper\MoSecurityHelper; -use OSP\Helper\MoSessionHelper; -use OSP\Traits\Instance; -use OTP\Helper\MoMessages; -use OTP\Helper\MoPHPSessions; -use OTP\Helper\MoUtility; - -if ( ! defined( 'ABSPATH' ) ) { - exit; -} - -if ( ! class_exists( 'MoOtpSpamAjax' ) ) { - /** - * Handles AJAX requests for spam prevention functionality. - */ - class MoOtpSpamAjax { - - use Instance; - - /** - * Storage instance - * - * @var MoOtpSpamStorage - */ - private $storage; - - /** - * Handler instance - * - * @var MoOtpSpamPreventerHandler - */ - private $handler; - - /** - * Constructor - */ - public function __construct() { - $this->storage = MoOtpSpamStorage::instance(); - $this->handler = MoOtpSpamPreventerHandler::instance(); - - add_action( 'wp_ajax_mo_osp_check_spam', array( $this, 'mosp_check_spam_ajax' ) ); - add_action( 'wp_ajax_nopriv_mo_osp_check_spam', array( $this, 'mosp_check_spam_ajax' ) ); - - add_action( 'wp_ajax_mo_osp_log_attempt', array( $this, 'mosp_log_attempt_ajax' ) ); - add_action( 'wp_ajax_nopriv_mo_osp_log_attempt', array( $this, 'mosp_log_attempt_ajax' ) ); - - add_action( 'wp_ajax_mo_osp_save_settings', array( $this, 'mosp_save_settings_ajax' ) ); - - add_action( 'wp_ajax_mo_osp_check_puzzle', array( $this, 'mosp_check_puzzle_ajax' ) ); - add_action( 'wp_ajax_nopriv_mo_osp_check_puzzle', array( $this, 'mosp_check_puzzle_ajax' ) ); - add_action( 'wp_ajax_mo_osp_generate_puzzle', array( $this, 'mosp_generate_puzzle_ajax' ) ); - add_action( 'wp_ajax_nopriv_mo_osp_generate_puzzle', array( $this, 'mosp_generate_puzzle_ajax' ) ); - add_action( 'wp_ajax_mo_osp_verify_puzzle', array( $this, 'mosp_verify_puzzle_ajax' ) ); - add_action( 'wp_ajax_nopriv_mo_osp_verify_puzzle', array( $this, 'mosp_verify_puzzle_ajax' ) ); - - add_action( 'wp_ajax_mo_osp_check_timer_status', array( $this, 'mosp_check_timer_status_ajax' ) ); - add_action( 'wp_ajax_nopriv_mo_osp_check_timer_status', array( $this, 'mosp_check_timer_status_ajax' ) ); - - add_action( 'wp_ajax_mo_osp_check_puzzle_requirement', array( $this, 'mosp_check_puzzle_requirement_ajax' ) ); - add_action( 'wp_ajax_nopriv_mo_osp_check_puzzle_requirement', array( $this, 'mosp_check_puzzle_requirement_ajax' ) ); - - add_action( 'wp_ajax_mo_osp_check_blocked', array( $this, 'mosp_check_blocked_ajax' ) ); - add_action( 'wp_ajax_nopriv_mo_osp_check_blocked', array( $this, 'mosp_check_blocked_ajax' ) ); - - add_action( 'wp_ajax_mo_osp_unblock_user', array( $this, 'mosp_unblock_user_ajax' ) ); - add_action( 'wp_ajax_nopriv_mo_osp_unblock_user', array( $this, 'mosp_unblock_user_ajax' ) ); - - add_action( 'wp_ajax_mo_osp_get_blocked_users', array( $this, 'mosp_get_blocked_users_ajax' ) ); - add_action( 'wp_ajax_mo_osp_unblock_user_by_hash', array( $this, 'mosp_unblock_user_by_hash_ajax' ) ); - add_action( 'wp_ajax_mo_osp_clear_all_blocked_users', array( $this, 'mosp_clear_all_blocked_users_ajax' ) ); - add_action( 'wp_ajax_mo_osp_toggle_addon', array( $this, 'mosp_toggle_addon_ajax' ) ); - } - - /** - * AJAX handler for checking spam before OTP send. - * - * @return void Sends JSON response. - */ - public function mosp_check_spam_ajax() { - check_ajax_referer( 'mo_osp_nonce', 'security' ); - - $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; - $phone = isset( $_POST['phone'] ) ? sanitize_text_field( wp_unslash( $_POST['phone'] ) ) : ''; - $browser_id = isset( $_POST['browser_id'] ) ? sanitize_text_field( wp_unslash( $_POST['browser_id'] ) ) : ''; - - if ( empty( $email ) && empty( $phone ) ) { - wp_send_json_error( - array( 'message' => __( 'Phone or email is required', 'miniorange-otp-verification' ) ), - 400 - ); - } - - if ( $browser_id ) { - $_POST['mo_osp_browser_id'] = $browser_id; - } - - $result = $this->handler->mosp_check_spam_before_otp_send( true, '', $email, $phone ); - - if ( is_wp_error( $result ) ) { - wp_send_json_error( - array( - 'message' => $result->get_error_message(), - 'code' => $result->get_error_code(), - ), - 429 - ); - } - - wp_send_json_success( - array( 'message' => __( 'Request allowed', 'miniorange-otp-verification' ) ) - ); - } - - /** - * AJAX handler for logging OTP attempts (for checkout mode) - * - * @return void Sends JSON response. - */ - public function mosp_log_attempt_ajax() { - check_ajax_referer( 'mo_osp_nonce', 'security' ); - - $browser_id = isset( $_POST['browser_id'] ) ? sanitize_text_field( wp_unslash( $_POST['browser_id'] ) ) : ''; - $mode = isset( $_POST['mode'] ) ? sanitize_text_field( wp_unslash( $_POST['mode'] ) ) : ''; - - if ( empty( $browser_id ) ) { - wp_send_json_error( - array( 'message' => __( 'Browser ID is required', 'miniorange-otp-verification' ) ), - 400 - ); - } - - $_POST['mo_osp_browser_id'] = $browser_id; - - $this->handler->mosp_record_otp_attempt( '', '', '' ); - - wp_send_json_success( - array( 'message' => __( 'Attempt logged', 'miniorange-otp-verification' ) ) - ); - } - - /** - * AJAX handler for saving settings (admin only). - * - * @return void Sends JSON response. - */ - public function mosp_save_settings_ajax() { - check_ajax_referer( 'mo_osp_admin_nonce', 'security' ); - - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( - array( 'message' => __( 'Insufficient permissions', 'miniorange-otp-verification' ) ), - 403 - ); - } - - $settings = $this->storage->mosp_get_settings(); - - if ( isset( $_POST['enabled'] ) ) { - $settings['enabled'] = ( 1 === absint( $_POST['enabled'] ) ); - } - - $settings['cooldown_time'] = isset( $_POST['cooldown_time'] ) ? absint( $_POST['cooldown_time'] ) : 60; - - $max_attempts = isset( $_POST['max_attempts'] ) ? absint( $_POST['max_attempts'] ) : 3; - $settings['max_attempts'] = max( 1, min( 10, $max_attempts ) ); - - $settings['block_time'] = isset( $_POST['block_time'] ) ? absint( $_POST['block_time'] ) : 900; - - $settings['daily_limit'] = isset( $_POST['daily_limit'] ) ? absint( $_POST['daily_limit'] ) : 10; - $settings['hourly_limit'] = isset( $_POST['hourly_limit'] ) ? absint( $_POST['hourly_limit'] ) : 5; - - $settings['track_phone'] = true; - $settings['track_email'] = true; - $settings['track_ip'] = true; - $settings['track_browser'] = true; - - $whitelist_ips = isset( $_POST['whitelist_ips'] ) ? sanitize_textarea_field( wp_unslash( $_POST['whitelist_ips'] ) ) : ''; - $settings['whitelist_ips'] = array_filter( array_map( 'trim', explode( "\n", $whitelist_ips ) ) ); - $settings['whitelist_ips'] = array_values( $settings['whitelist_ips'] ); - - $validation_errors = $this->mosp_validate_settings( $settings ); - if ( ! empty( $validation_errors ) ) { - wp_send_json_error( - array( - 'message' => __( 'Invalid settings', 'miniorange-otp-verification' ), - 'errors' => $validation_errors, - ), - 400 - ); - } - - $result = $this->storage->mosp_update_settings( $settings ); - - if ( $result ) { - wp_send_json_success( - array( 'message' => __( 'Settings saved successfully', 'miniorange-otp-verification' ) ) - ); - } else { - wp_send_json_error( - array( 'message' => __( 'Failed to save settings', 'miniorange-otp-verification' ) ), - 500 - ); - } - } - - /** - * Validate settings array - * - * @param array $settings Settings to validate. - * @return array Validation errors - */ - private function mosp_validate_settings( $settings ) { - $errors = array(); - - if ( $settings['cooldown_time'] < 0 || $settings['cooldown_time'] > 86400 ) { - $errors['cooldown_time'] = __( 'Cooldown time must be between 0 and 86400 seconds', 'miniorange-otp-verification' ); - } - - if ( $settings['max_attempts'] < 1 || $settings['max_attempts'] > 10 ) { - $errors['max_attempts'] = __( 'Max attempts must be between 1 and 10', 'miniorange-otp-verification' ); - } - - if ( $settings['block_time'] < 60 || $settings['block_time'] > 604800 ) { - $errors['block_time'] = __( 'Block time must be between 60 and 604800 seconds', 'miniorange-otp-verification' ); - } - if ( $settings['daily_limit'] < 1 || $settings['daily_limit'] > 1000 ) { - $errors['daily_limit'] = __( 'Daily limit must be between 1 and 1000', 'miniorange-otp-verification' ); - } - - if ( $settings['hourly_limit'] < 1 || $settings['hourly_limit'] > 100 ) { - $errors['hourly_limit'] = __( 'Hourly limit must be between 1 and 100', 'miniorange-otp-verification' ); - } - - if ( $settings['hourly_limit'] <= $settings['max_attempts'] ) { - $errors['hourly_limit'] = sprintf( - /* translators: %d: max attempts value */ - __( 'Hourly limit must be greater than max attempts per window (%d)', 'miniorange-otp-verification' ), - $settings['max_attempts'] - ); - } - - if ( $settings['daily_limit'] <= $settings['hourly_limit'] ) { - $errors['daily_limit'] = sprintf( - /* translators: %d: hourly limit value */ - __( 'Daily limit must be greater than hourly limit (%d)', 'miniorange-otp-verification' ), - $settings['hourly_limit'] - ); - } - - if ( ! $settings['track_phone'] && ! $settings['track_email'] && ! $settings['track_ip'] && ! $settings['track_browser'] ) { - $errors['tracking'] = __( 'At least one tracking method must be enabled', 'miniorange-otp-verification' ); - } - - foreach ( $settings['whitelist_ips'] as $ip ) { - if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { - $errors['whitelist_ips'] = sprintf( - /* translators: %s: invalid IP address */ - __( 'Invalid IP address: %s', 'miniorange-otp-verification' ), - $ip - ); - break; - } - } - return $errors; - } - - /** - * SECURITY ENHANCEMENT: AJAX handler for generating secure puzzles - * - * This method generates a new puzzle and stores it securely in the session, - * preventing client-side manipulation of puzzle data. - * - * @return void Sends JSON response. - */ - public function mosp_generate_puzzle_ajax() { - if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ), 'mo_osp_nonce' ) ) { - wp_send_json_error( array( 'message' => __( 'Security check failed', 'miniorange-otp-verification' ) ) ); - } - - $puzzle = MoPuzzleHelper::mosp_generate_secure_puzzle(); - - if ( ! $puzzle ) { - wp_send_json_error( array( 'message' => __( 'Failed to generate puzzle', 'miniorange-otp-verification' ) ) ); - } - - MoPuzzleHelper::mosp_store_puzzle_in_session( $puzzle['question'], $puzzle['answer'] ); - - $puzzle_image = MoPuzzleHelper::mosp_generate_puzzle_image( $puzzle['question'] ); - - $response = array( - 'question' => $puzzle['question'], - 'message' => __( 'Puzzle generated successfully', 'miniorange-otp-verification' ), - ); - - if ( $puzzle_image ) { - $response['image'] = $puzzle_image; - } - - wp_send_json_success( $response ); - } - - /** - * AJAX handler for checking if puzzle verification is required - * - * @return void Sends JSON response. - */ - public function mosp_check_puzzle_ajax() { - if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ), 'mo_osp_nonce' ) ) { - wp_send_json_error( array( 'message' => __( 'Security check failed', 'miniorange-otp-verification' ) ) ); - } - - $email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) ); - $phone = sanitize_text_field( wp_unslash( $_POST['phone'] ?? '' ) ); - $browser_id = sanitize_text_field( wp_unslash( $_POST['browser_id'] ?? '' ) ); - $ip = $this->handler->mosp_get_client_ip(); - - if ( ! empty( $phone ) ) { - $phone = MoUtility::process_phone_number( $phone ); - $digit_count = strlen( preg_replace( '/\D/', '', $phone ) ); - if ( $digit_count < 6 ) { - $phone = ''; - } - } - - if ( ! empty( $email ) ) { - MoPHPSessions::add_session_var( 'user_email', $email ); - } - if ( ! empty( $phone ) ) { - MoPHPSessions::add_session_var( 'phone_number_mo', $phone ); - } - $requires_puzzle = $this->handler->mosp_requires_puzzle_verification( $email, $phone, $ip, $browser_id ); - - wp_send_json_success( - array( - 'requires_puzzle' => $requires_puzzle, - 'message' => $requires_puzzle ? __( 'Puzzle verification required', 'miniorange-otp-verification' ) : __( 'No puzzle required', 'miniorange-otp-verification' ), - ) - ); - } - - /** - * AJAX handler for verifying puzzle completion - * SECURITY ENHANCEMENT: Uses session-stored puzzle data for validation - * - * @return void Sends JSON response. - */ - public function mosp_verify_puzzle_ajax() { - if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ), 'mo_osp_nonce' ) ) { - wp_send_json_error( array( 'message' => __( 'Security check failed', 'miniorange-otp-verification' ) ) ); - } - - $email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) ); - $phone = sanitize_text_field( wp_unslash( $_POST['phone'] ?? '' ) ); - $browser_id = sanitize_text_field( wp_unslash( $_POST['browser_id'] ?? '' ) ); - $ip = $this->handler->mosp_get_client_ip(); - - if ( ! empty( $phone ) ) { - $phone = MoUtility::process_phone_number( $phone ); - $digit_count = strlen( preg_replace( '/\D/', '', $phone ) ); - if ( $digit_count < 6 ) { - $phone = ''; - } - } - if ( empty( $email ) ) { - $email = MoPHPSessions::get_session_var( 'user_email' ); - } - $session_phone = MoPHPSessions::get_session_var( 'phone_number_mo' ); - if ( empty( $phone ) && ! empty( $session_phone ) ) { - $phone = $session_phone; - } - if ( ! empty( $phone ) ) { - $phone = MoUtility::process_phone_number( $phone ); - $digit_count = strlen( preg_replace( '/\D/', '', $phone ) ); - if ( $digit_count < 6 ) { - $phone = ''; - } - } - if ( ! empty( $session_phone ) ) { - $normalized_session_phone = MoUtility::process_phone_number( $session_phone ); - $session_digits = strlen( preg_replace( '/\D/', '', $normalized_session_phone ) ); - if ( $session_digits >= 6 ) { - $phone = $normalized_session_phone; - } - } - - $has_answer = array_key_exists( 'puzzle_answer', $_POST ); - $puzzle_answer = $has_answer ? intval( wp_unslash( $_POST['puzzle_answer'] ) ) : null; - - if ( ! $has_answer ) { - wp_send_json_error( array( 'message' => __( 'Puzzle answer is required', 'miniorange-otp-verification' ) ) ); - } - - if ( ! MoPuzzleHelper::mosp_has_puzzle_in_session() ) { - wp_send_json_error( array( 'message' => __( 'Puzzle session expired. Please refresh and try again.', 'miniorange-otp-verification' ) ) ); - } - - $is_valid = MoPuzzleHelper::mosp_validate_puzzle_answer_from_session( $puzzle_answer ); - - if ( ! $is_valid ) { - $puzzle_attempts_key = 'mo_osp_puzzle_attempts_' . md5( $ip . MoSecurityHelper::mosp_get_user_agent() ); - $incorrect_attempts = MoPHPSessions::get_session_var( $puzzle_attempts_key ); - - if ( false === $incorrect_attempts ) { - $incorrect_attempts = 0; - } - - ++$incorrect_attempts; - - $max_attempts_per_puzzle = 2; - - if ( $incorrect_attempts >= $max_attempts_per_puzzle ) { - MoPHPSessions::unset_session( $puzzle_attempts_key ); - - $new_puzzle = MoPuzzleHelper::mosp_generate_secure_puzzle(); - MoPuzzleHelper::mosp_store_puzzle_in_session( - $new_puzzle['question'], - $new_puzzle['answer'], - $ip, - MoSecurityHelper::mosp_get_user_agent() - ); - - $puzzle_image = MoPuzzleHelper::mosp_generate_puzzle_image( $new_puzzle['question'] ); - - $response_data = array( - 'message' => __( 'Incorrect puzzle answer. A new puzzle has been generated. Please solve it.', 'miniorange-otp-verification' ), - 'puzzle_reset' => true, - ); - - if ( $puzzle_image ) { - $response_data['puzzle_image'] = $puzzle_image; - } else { - $response_data['puzzle_question'] = $new_puzzle['question']; - } - - wp_send_json_error( $response_data ); - } else { - MoPHPSessions::add_session_var( $puzzle_attempts_key, $incorrect_attempts ); - $remaining_attempts = $max_attempts_per_puzzle - $incorrect_attempts; - - wp_send_json_error( - array( - 'message' => sprintf( - /* translators: %d: number of remaining attempts */ - __( 'Incorrect answer. Please try again. (%d attempt(s) remaining)', 'miniorange-otp-verification' ), - $remaining_attempts - ), - 'puzzle_reset' => false, - 'remaining_attempts' => $remaining_attempts, - ) - ); - } - } else { - $puzzle_attempts_key = 'mo_osp_puzzle_attempts_' . md5( $ip . MoSecurityHelper::mosp_get_user_agent() ); - MoPHPSessions::unset_session( $puzzle_attempts_key ); - } - - $this->handler->mosp_reset_immediate_spam_protection( $email, $phone ); - $requires_limit_puzzle = $this->handler->mosp_requires_limit_puzzle_verification( $email, $phone ); - if ( $requires_limit_puzzle ) { - $this->handler->mosp_mark_limit_puzzle_completed( $email, $phone ); - } else { - MoSecurityHelper::mosp_mark_puzzle_verification_complete( $email, $phone ); - } - - $verification_token = MoSecurityHelper::mosp_generate_puzzle_verification_token( $email, $phone ); - - wp_send_json_success( - array( - 'message' => __( 'Puzzle verified successfully', 'miniorange-otp-verification' ), - 'puzzle_cleared' => true, - 'verification_token' => $verification_token, - 'puzzle_nonce' => wp_create_nonce( 'mo_osp_puzzle_verify' ), - ) - ); - } - - /** - * SECURITY FIX: Calculate the correct answer for a puzzle question - * - * @param string $question The puzzle question. - * @return int|false The correct answer or false if invalid. - */ - private function calculate_puzzle_answer( $question ) { - if ( preg_match( '/(\d+)\s*([+\-×÷*\/])\s*(\d+)/', $question, $matches ) ) { - $a = intval( $matches[1] ); - $operator = $matches[2]; - $b = intval( $matches[3] ); - - switch ( $operator ) { - case '+': - return $a + $b; - case '-': - return $a - $b; - case '×': - case '*': - return $a * $b; - case '÷': - case '/': - return 0 !== $b ? intval( $a / $b ) : false; - default: - return false; - } - } - - if ( preg_match( '/(\d+)\s*([+\-])\s*(\d+)\s*([+\-])\s*(\d+)/', $question, $matches ) ) { - $a = intval( $matches[1] ); - $op1 = $matches[2]; - $b = intval( $matches[3] ); - $op2 = $matches[4]; - $c = intval( $matches[5] ); - - $result = $a; - $result = ( '+' === $op1 ) ? $result + $b : $result - $b; - $result = ( '+' === $op2 ) ? $result + $c : $result - $c; - - return $result; - } - - return false; - } - - /** - * AJAX handler for checking timer status with persistent state management. - * - * @return void Sends JSON response. - */ - public function mosp_check_timer_status_ajax() { - if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ), 'mo_osp_nonce' ) ) { - wp_send_json_error( array( 'message' => __( 'Security check failed', 'miniorange-otp-verification' ) ) ); - } - - $browser_id = isset( $_POST['mo_osp_browser_id'] ) ? sanitize_text_field( wp_unslash( $_POST['mo_osp_browser_id'] ) ) : ''; - $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; - $phone = isset( $_POST['phone'] ) ? MoUtility::process_phone_number( sanitize_text_field( wp_unslash( $_POST['phone'] ) ) ) : ''; - - $state = $this->mosp_get_current_user_state( $email, $phone, $browser_id ); - wp_send_json_success( $state ); - } - - /** - * Get current user state with accurate timer information. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param string $browser_id Browser ID. - * @return array Current user state. - */ - private function mosp_get_current_user_state( $email, $phone, $browser_id ) { - $settings = $this->storage->mosp_get_settings(); - $current_time = time(); - - $is_blocked = $this->handler->mosp_is_blocked( $email, $phone, $browser_id, 'timer_status' ); - - if ( $is_blocked ) { - $block_data = $this->handler->mosp_get_block_data( $email, $phone, $browser_id, 'timer_status' ); - $block_remaining = $block_data['remaining_time']; - $block_reason = $block_data['reason']; - - if ( $block_remaining > 0 ) { - return array( - 'blocked' => true, - 'remaining_time' => $block_remaining, - 'blocked_type' => $block_reason, - 'message' => MoMessages::showMessage( MoMessages::USER_IS_BLOCKED_AJAX ), - ); - } - } - - $requires_regular_puzzle = $this->handler->mosp_requires_puzzle_verification( $email, $phone, $this->handler->mosp_get_client_ip(), $browser_id ); - $requires_limit_puzzle = $this->handler->mosp_requires_limit_puzzle_verification( $email, $phone ); - $requires_puzzle = $requires_regular_puzzle || $requires_limit_puzzle; - - if ( $requires_puzzle ) { - return array( - 'puzzle_required' => true, - 'message' => 'Please complete the security verification to continue.', - ); - } - - $ip = $this->handler->mosp_get_client_ip(); - $is_ip_whitelisted = false; - if ( ! empty( $ip ) ) { - $is_ip_whitelisted = $this->handler->mosp_is_whitelisted( $ip, 'ip' ); - } - - $cooldown_remaining = $is_ip_whitelisted ? 0 : $this->mosp_get_cooldown_remaining_time( $email, $phone, $browser_id ); - - if ( $cooldown_remaining > 0 ) { - return array( - 'status' => 'cooldown', - 'cooldown' => true, - 'timer_active' => true, - 'remaining_time' => $cooldown_remaining, - 'message' => MoMessages::showMessage( MoMessages::LIMIT_OTP_SENT ), - ); - } - - return array( - 'status' => 'ready', - 'message' => 'Ready to send OTP', - ); - } - - /** - * Get remaining cooldown time for user. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param string $browser_id Browser ID. - * @return int Remaining cooldown time. - */ - private function mosp_get_cooldown_remaining_time( $email, $phone, $browser_id ) { - try { - $identifiers = $this->handler->mosp_get_all_identifiers( $email, $phone, $this->handler->mosp_get_client_ip(), $browser_id ); - $current_time = time(); - $settings = $this->storage->mosp_get_settings(); - $cooldown_time = isset( $settings['cooldown_time'] ) ? (int) $settings['cooldown_time'] : 60; - - if ( empty( $identifiers ) || ! is_array( $identifiers ) ) { - return 0; - } - - foreach ( $identifiers as $identifier ) { - if ( empty( $identifier ) ) { - continue; - } - - $key = $this->storage->mosp_hash_key( $identifier ); - $data = $this->storage->mosp_get_spam_data( $key ); - - if ( false === $data || ! isset( $data['attempts'] ) || ! is_array( $data['attempts'] ) ) { - continue; - } - - $attempts = $data['attempts']; - $attempt_count = count( $attempts ); - - if ( $attempt_count < 2 ) { - continue; - } - - $sorted_attempts = $attempts; - rsort( $sorted_attempts ); - - if ( ! isset( $sorted_attempts[0] ) || ! isset( $sorted_attempts[1] ) ) { - continue; - } - - $most_recent_attempt = (int) $sorted_attempts[0]; - $second_to_last_attempt = (int) $sorted_attempts[1]; - - $time_between_attempts = $most_recent_attempt - $second_to_last_attempt; - - if ( $time_between_attempts < $cooldown_time ) { - $cooldown_expires_at = $second_to_last_attempt + $cooldown_time; - $remaining = $cooldown_expires_at - $current_time; - - if ( $remaining > 0 ) { - return $remaining; - } - } - } - } catch ( Exception $e ) { - return 0; - } catch ( Error $e ) { - return 0; - } - - return 0; - } - - /** - * AJAX handler for checking puzzle requirement (separate from timer status) - * - * @return void Sends JSON response. - */ - public function mosp_check_puzzle_requirement_ajax() { - if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'mo_osp_nonce' ) ) { - wp_send_json_error( array( 'message' => __( 'Security check failed', 'miniorange-otp-verification' ) ) ); - } - - $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; - $phone = isset( $_POST['phone'] ) ? sanitize_text_field( wp_unslash( $_POST['phone'] ) ) : ''; - $browser_id = isset( $_POST['mo_osp_browser_id'] ) ? sanitize_text_field( wp_unslash( $_POST['mo_osp_browser_id'] ) ) : ''; - - $requires_regular_puzzle = $this->handler->mosp_requires_puzzle_verification( $email, $phone, $this->handler->mosp_get_client_ip(), $browser_id ); - $requires_limit_puzzle = $this->handler->mosp_requires_limit_puzzle_verification( $email, $phone ); - $requires_puzzle = $requires_regular_puzzle || $requires_limit_puzzle; - - wp_send_json_success( - array( - 'puzzle_required' => $requires_puzzle, - 'regular_puzzle' => $requires_regular_puzzle, - 'limit_puzzle' => $requires_limit_puzzle, - 'message' => $requires_puzzle ? MoMessages::showMessage( MoMessages::PLEASE_VALIDATE ) : __( 'No puzzle required', 'miniorange-otp-verification' ), - ) - ); - } - - /** - * AJAX handler for checking if user is blocked in popup (similar to resendcontrol) - * - * Checks if the user is blocked and sends a JSON response. - * This function calculates the remaining block time and cooldown for a user. - * If the user is still blocked or on cooldown, it returns the remaining time. - * - * @return void Sends a JSON response with the blocked status and remaining time. - */ - public function mosp_check_blocked_ajax() { - // phpcs:disable WordPress.Security.NonceVerification.Missing -- Public AJAX endpoint, nonce not required for read operations. - try { - $browser_id = isset( $_POST['mo_osp_browser_id'] ) ? sanitize_text_field( wp_unslash( $_POST['mo_osp_browser_id'] ) ) : ''; - $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; - $phone = isset( $_POST['phone'] ) ? sanitize_text_field( wp_unslash( $_POST['phone'] ) ) : ''; - - $is_blocked = $this->handler->mosp_is_blocked( $email, $phone, $browser_id, 'timer_status' ); - - if ( $is_blocked ) { - $block_data = $this->handler->mosp_get_block_data( $email, $phone, $browser_id, 'timer_status' ); - $block_remaining = isset( $block_data['remaining_time'] ) ? $block_data['remaining_time'] : 0; - $block_reason = isset( $block_data['reason'] ) ? $block_data['reason'] : ''; - - if ( $block_remaining > 0 ) { - wp_send_json( - array( - 'blocked' => true, - 'remaining_time' => $block_remaining, - 'blocked_type' => $block_reason, - 'message' => MoMessages::showMessage( MoMessages::USER_IS_BLOCKED_AJAX ), - ) - ); - return; - } - } - - $ip = $this->handler->mosp_get_client_ip(); - $is_ip_whitelisted = false; - if ( ! empty( $ip ) ) { - $is_ip_whitelisted = $this->handler->mosp_is_whitelisted( $ip, 'ip' ); - } - - $cooldown_remaining = $is_ip_whitelisted ? 0 : $this->mosp_get_cooldown_remaining_time( $email, $phone, $browser_id ); - - if ( $cooldown_remaining > 0 ) { - wp_send_json( - array( - 'cooldown' => true, - 'remaining_time' => $cooldown_remaining, - 'message' => MoMessages::showMessage( MoMessages::LIMIT_OTP_SENT ), - ) - ); - return; - } - - wp_send_json( - array( - 'blocked' => false, - 'cooldown' => false, - ) - ); - } catch ( Exception $e ) { - wp_send_json_error( - array( - 'message' => __( 'An error occurred while checking status.', 'miniorange-otp-verification' ), - 'error' => defined( 'WP_DEBUG' ) && WP_DEBUG ? $e->getMessage() : '', - ) - ); - } catch ( Error $e ) { - wp_send_json_error( - array( - 'message' => __( 'An error occurred while checking status.', 'miniorange-otp-verification' ), - 'error' => defined( 'WP_DEBUG' ) && WP_DEBUG ? $e->getMessage() : '', - ) - ); - } - } - - /** - * AJAX handler for unblocking user in popup (similar to resendcontrol) - * - * Unblocks a user if the block duration has expired and sends a JSON response. - * This function checks whether the user's block time has expired. If expired, - * it removes the block and sends a response indicating the user is unblocked. - * - * @return void Sends a JSON response with the blocked/unblocked status. - */ - public function mosp_unblock_user_ajax() { - // phpcs:disable WordPress.Security.NonceVerification.Missing -- Public AJAX endpoint, nonce not required for read operations. - $browser_id = isset( $_POST['mo_osp_browser_id'] ) ? sanitize_text_field( wp_unslash( $_POST['mo_osp_browser_id'] ) ) : ''; - $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; - $phone = isset( $_POST['phone'] ) ? sanitize_text_field( wp_unslash( $_POST['phone'] ) ) : ''; - - $is_blocked = $this->handler->mosp_is_blocked( $email, $phone, $browser_id, 'timer_status' ); - - if ( ! $is_blocked ) { - wp_send_json( array( 'unblocked' => true ) ); - return; - } - - $block_data = $this->handler->mosp_get_block_data( $email, $phone, $browser_id, 'timer_status' ); - $block_remaining = $block_data['remaining_time']; - - wp_send_json( - array( - 'blocked' => true, - 'remaining_time' => $block_remaining, - ) - ); - } - - /** - * AJAX handler for getting list of blocked users (admin only). - * - * @return void Sends JSON response with blocked users list. - */ - public function mosp_get_blocked_users_ajax() { - check_ajax_referer( 'mo_osp_admin_nonce', 'security' ); - - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( - array( 'message' => __( 'Insufficient permissions', 'miniorange-otp-verification' ) ), - 403 - ); - } - - $limit = isset( $_POST['limit'] ) ? absint( $_POST['limit'] ) : 100; - $offset = isset( $_POST['offset'] ) ? absint( $_POST['offset'] ) : 0; - - $result = $this->storage->mosp_get_all_blocked_users( $limit, $offset ); - - foreach ( $result['users'] as &$user ) { - $user['remaining_time_formatted'] = $this->mosp_format_time( $user['remaining_time'] ); - $user['block_reason_label'] = $this->mosp_get_block_reason_label( $user['block_reason'] ); - } - - wp_send_json_success( $result ); - } - - /** - * AJAX handler for unblocking user by hash (admin only). - * - * @return void Sends JSON response. - */ - public function mosp_unblock_user_by_hash_ajax() { - check_ajax_referer( 'mo_osp_admin_nonce', 'security' ); - - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( - array( 'message' => __( 'Insufficient permissions', 'miniorange-otp-verification' ) ), - 403 - ); - } - - $identifier_hash = isset( $_POST['identifier_hash'] ) ? sanitize_text_field( wp_unslash( $_POST['identifier_hash'] ) ) : ''; - - if ( empty( $identifier_hash ) ) { - wp_send_json_error( - array( 'message' => __( 'Invalid identifier hash', 'miniorange-otp-verification' ) ), - 400 - ); - } - - $result = $this->handler->mosp_unblock_user_by_hash( $identifier_hash ); - - if ( $result['success'] ) { - wp_send_json_success( array( 'message' => $result['message'] ) ); - } else { - wp_send_json_error( - array( 'message' => $result['message'] ), - 400 - ); - } - } - - /** - * AJAX handler: clear all blocked users / limits / puzzle flags (admin only). - * - * @return void - */ - public function mosp_clear_all_blocked_users_ajax() { - check_ajax_referer( 'mo_osp_admin_nonce', 'security' ); - - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( - array( 'message' => __( 'Insufficient permissions', 'miniorange-otp-verification' ) ), - 403 - ); - } - - $result = $this->handler->mosp_clear_all_blocked_data(); - - if ( ! $result['success'] ) { - wp_send_json_error( array( 'message' => $result['message'] ) ); - } - - wp_send_json_success( - array( - 'message' => $result['message'], - 'deleted' => $result['deleted'], - ) - ); - } - - /** - * AJAX handler for enabling/disabling addon (admin only). - * - * @return void Sends JSON response. - */ - public function mosp_toggle_addon_ajax() { - check_ajax_referer( 'mo_osp_admin_nonce', 'security' ); - - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( - array( 'message' => __( 'Insufficient permissions', 'miniorange-otp-verification' ) ), - 403 - ); - } - - $enabled = isset( $_POST['enabled'] ) ? absint( $_POST['enabled'] ) : 0; - $settings = $this->storage->mosp_get_settings(); - $settings['enabled'] = ( 1 === $enabled ); - - $result = $this->storage->mosp_update_settings( $settings ); - - if ( $result ) { - $message = $settings['enabled'] - ? __( 'Addon enabled.', 'miniorange-otp-verification' ) - : __( 'Addon disabled.', 'miniorange-otp-verification' ); - wp_send_json_success( array( 'message' => $message ) ); - } - - wp_send_json_error( - array( 'message' => __( 'Failed to update addon status.', 'miniorange-otp-verification' ) ), - 500 - ); - } - - /** - * Format time in seconds to human-readable format. - * - * @param int $seconds Time in seconds. - * @return string Formatted time string. - */ - private function mosp_format_time( $seconds ) { - if ( $seconds < 60 ) { - // translators: %d: Number of seconds. - return sprintf( _n( '%d second', '%d seconds', $seconds, 'miniorange-otp-verification' ), $seconds ); - } elseif ( $seconds < 3600 ) { - $minutes = floor( $seconds / 60 ); - $secs = $seconds % 60; - if ( $secs > 0 ) { - // translators: %d: Number of minutes. - $minutes_str = sprintf( _n( '%d minute', '%d minutes', $minutes, 'miniorange-otp-verification' ), $minutes ); - // translators: %d: Number of seconds. - $seconds_str = sprintf( _n( '%d second', '%d seconds', $secs, 'miniorange-otp-verification' ), $secs ); - return $minutes_str . ' ' . $seconds_str; - } - // translators: %d: Number of minutes. - return sprintf( _n( '%d minute', '%d minutes', $minutes, 'miniorange-otp-verification' ), $minutes ); - } else { - $hours = floor( $seconds / 3600 ); - $minutes = floor( ( $seconds % 3600 ) / 60 ); - if ( $minutes > 0 ) { - // translators: %d: Number of hours. - $hours_str = sprintf( _n( '%d hour', '%d hours', $hours, 'miniorange-otp-verification' ), $hours ); - // translators: %d: Number of minutes. - $minutes_str = sprintf( _n( '%d minute', '%d minutes', $minutes, 'miniorange-otp-verification' ), $minutes ); - return $hours_str . ' ' . $minutes_str; - } - // translators: %d: Number of hours. - return sprintf( _n( '%d hour', '%d hours', $hours, 'miniorange-otp-verification' ), $hours ); - } - } - - /** - * Get human-readable label for block reason. - * - * @param string $reason Block reason code. - * @return string Human-readable label. - */ - private function mosp_get_block_reason_label( $reason ) { - switch ( $reason ) { - case 'hourly_limit_exceeded': - return __( 'Hourly Limit Exceeded', 'miniorange-otp-verification' ); - case 'daily_limit_exceeded': - return __( 'Daily Limit Exceeded', 'miniorange-otp-verification' ); - case 'max_attempts_exceeded': - return __( 'Max Attempts Exceeded', 'miniorange-otp-verification' ); - case 'cooldown': - return __( 'Cooldown Period', 'miniorange-otp-verification' ); - default: - return __( 'Blocked', 'miniorange-otp-verification' ); - } - } - } -} +<?php +/** + * OTP Spam AJAX Handler + * + * @package otpspampreventer/handler + */ + +namespace OSP\Handler; + +use OSP\Handler\MoOtpSpamStorage; +use OSP\Handler\MoOtpSpamPreventerHandler; +use OSP\Helper\MoPuzzleHelper; +use OSP\Helper\MoSecurityHelper; +use OSP\Helper\MoSessionHelper; +use OSP\Traits\Instance; +use OTP\Helper\MoMessages; +use OTP\Helper\MoPHPSessions; +use OTP\Helper\MoUtility; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +if ( ! class_exists( 'MoOtpSpamAjax' ) ) { + /** + * Handles AJAX requests for spam prevention functionality. + */ + class MoOtpSpamAjax { + + use Instance; + + /** + * Storage instance + * + * @var MoOtpSpamStorage + */ + private $storage; + + /** + * Handler instance + * + * @var MoOtpSpamPreventerHandler + */ + private $handler; + + /** + * Constructor + */ + public function __construct() { + $this->storage = MoOtpSpamStorage::instance(); + $this->handler = MoOtpSpamPreventerHandler::instance(); + + add_action( 'wp_ajax_mo_osp_check_spam', array( $this, 'mosp_check_spam_ajax' ) ); + add_action( 'wp_ajax_nopriv_mo_osp_check_spam', array( $this, 'mosp_check_spam_ajax' ) ); + + add_action( 'wp_ajax_mo_osp_log_attempt', array( $this, 'mosp_log_attempt_ajax' ) ); + add_action( 'wp_ajax_nopriv_mo_osp_log_attempt', array( $this, 'mosp_log_attempt_ajax' ) ); + + add_action( 'wp_ajax_mo_osp_save_settings', array( $this, 'mosp_save_settings_ajax' ) ); + + add_action( 'wp_ajax_mo_osp_check_puzzle', array( $this, 'mosp_check_puzzle_ajax' ) ); + add_action( 'wp_ajax_nopriv_mo_osp_check_puzzle', array( $this, 'mosp_check_puzzle_ajax' ) ); + add_action( 'wp_ajax_mo_osp_generate_puzzle', array( $this, 'mosp_generate_puzzle_ajax' ) ); + add_action( 'wp_ajax_nopriv_mo_osp_generate_puzzle', array( $this, 'mosp_generate_puzzle_ajax' ) ); + add_action( 'wp_ajax_mo_osp_verify_puzzle', array( $this, 'mosp_verify_puzzle_ajax' ) ); + add_action( 'wp_ajax_nopriv_mo_osp_verify_puzzle', array( $this, 'mosp_verify_puzzle_ajax' ) ); + + add_action( 'wp_ajax_mo_osp_check_timer_status', array( $this, 'mosp_check_timer_status_ajax' ) ); + add_action( 'wp_ajax_nopriv_mo_osp_check_timer_status', array( $this, 'mosp_check_timer_status_ajax' ) ); + + add_action( 'wp_ajax_mo_osp_check_puzzle_requirement', array( $this, 'mosp_check_puzzle_requirement_ajax' ) ); + add_action( 'wp_ajax_nopriv_mo_osp_check_puzzle_requirement', array( $this, 'mosp_check_puzzle_requirement_ajax' ) ); + + add_action( 'wp_ajax_mo_osp_check_blocked', array( $this, 'mosp_check_blocked_ajax' ) ); + add_action( 'wp_ajax_nopriv_mo_osp_check_blocked', array( $this, 'mosp_check_blocked_ajax' ) ); + + add_action( 'wp_ajax_mo_osp_unblock_user', array( $this, 'mosp_unblock_user_ajax' ) ); + add_action( 'wp_ajax_nopriv_mo_osp_unblock_user', array( $this, 'mosp_unblock_user_ajax' ) ); + + add_action( 'wp_ajax_mo_osp_get_blocked_users', array( $this, 'mosp_get_blocked_users_ajax' ) ); + add_action( 'wp_ajax_mo_osp_unblock_user_by_hash', array( $this, 'mosp_unblock_user_by_hash_ajax' ) ); + add_action( 'wp_ajax_mo_osp_clear_all_blocked_users', array( $this, 'mosp_clear_all_blocked_users_ajax' ) ); + add_action( 'wp_ajax_mo_osp_toggle_addon', array( $this, 'mosp_toggle_addon_ajax' ) ); + } + + /** + * AJAX handler for checking spam before OTP send. + * + * @return void Sends JSON response. + */ + public function mosp_check_spam_ajax() { + check_ajax_referer( 'mo_osp_nonce', 'security' ); + + $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; + $phone = isset( $_POST['phone'] ) ? sanitize_text_field( wp_unslash( $_POST['phone'] ) ) : ''; + $browser_id = isset( $_POST['browser_id'] ) ? sanitize_text_field( wp_unslash( $_POST['browser_id'] ) ) : ''; + + if ( empty( $email ) && empty( $phone ) ) { + wp_send_json_error( + array( 'message' => __( 'Phone or email is required', 'miniorange-otp-verification' ) ), + 400 + ); + } + + if ( $browser_id ) { + $_POST['mo_osp_browser_id'] = $browser_id; + } + + $result = $this->handler->mosp_check_spam_before_otp_send( true, '', $email, $phone ); + + if ( is_wp_error( $result ) ) { + wp_send_json_error( + array( + 'message' => $result->get_error_message(), + 'code' => $result->get_error_code(), + ), + 429 + ); + } + + wp_send_json_success( + array( 'message' => __( 'Request allowed', 'miniorange-otp-verification' ) ) + ); + } + + /** + * AJAX handler for logging OTP attempts (for checkout mode) + * + * @return void Sends JSON response. + */ + public function mosp_log_attempt_ajax() { + check_ajax_referer( 'mo_osp_nonce', 'security' ); + + $browser_id = isset( $_POST['browser_id'] ) ? sanitize_text_field( wp_unslash( $_POST['browser_id'] ) ) : ''; + $mode = isset( $_POST['mode'] ) ? sanitize_text_field( wp_unslash( $_POST['mode'] ) ) : ''; + + if ( empty( $browser_id ) ) { + wp_send_json_error( + array( 'message' => __( 'Browser ID is required', 'miniorange-otp-verification' ) ), + 400 + ); + } + + $_POST['mo_osp_browser_id'] = $browser_id; + + $this->handler->mosp_record_otp_attempt( '', '', '' ); + + wp_send_json_success( + array( 'message' => __( 'Attempt logged', 'miniorange-otp-verification' ) ) + ); + } + + /** + * AJAX handler for saving settings (admin only). + * + * @return void Sends JSON response. + */ + public function mosp_save_settings_ajax() { + check_ajax_referer( 'mo_osp_admin_nonce', 'security' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( + array( 'message' => __( 'Insufficient permissions', 'miniorange-otp-verification' ) ), + 403 + ); + } + + $settings = $this->storage->mosp_get_settings(); + + if ( isset( $_POST['enabled'] ) ) { + $settings['enabled'] = ( 1 === absint( $_POST['enabled'] ) ); + } + + $settings['cooldown_time'] = isset( $_POST['cooldown_time'] ) ? absint( $_POST['cooldown_time'] ) : 60; + + $max_attempts = isset( $_POST['max_attempts'] ) ? absint( $_POST['max_attempts'] ) : 3; + $settings['max_attempts'] = max( 1, min( 10, $max_attempts ) ); + + $settings['block_time'] = isset( $_POST['block_time'] ) ? absint( $_POST['block_time'] ) : 900; + + $settings['daily_limit'] = isset( $_POST['daily_limit'] ) ? absint( $_POST['daily_limit'] ) : 10; + $settings['hourly_limit'] = isset( $_POST['hourly_limit'] ) ? absint( $_POST['hourly_limit'] ) : 5; + + $settings['track_phone'] = true; + $settings['track_email'] = true; + $settings['track_ip'] = true; + $settings['track_browser'] = true; + + $whitelist_ips = isset( $_POST['whitelist_ips'] ) ? sanitize_textarea_field( wp_unslash( $_POST['whitelist_ips'] ) ) : ''; + $settings['whitelist_ips'] = array_filter( array_map( 'trim', explode( "\n", $whitelist_ips ) ) ); + $settings['whitelist_ips'] = array_values( $settings['whitelist_ips'] ); + + $validation_errors = $this->mosp_validate_settings( $settings ); + if ( ! empty( $validation_errors ) ) { + wp_send_json_error( + array( + 'message' => __( 'Invalid settings', 'miniorange-otp-verification' ), + 'errors' => $validation_errors, + ), + 400 + ); + } + + $result = $this->storage->mosp_update_settings( $settings ); + + if ( $result ) { + wp_send_json_success( + array( 'message' => __( 'Settings saved successfully', 'miniorange-otp-verification' ) ) + ); + } else { + wp_send_json_error( + array( 'message' => __( 'Failed to save settings', 'miniorange-otp-verification' ) ), + 500 + ); + } + } + + /** + * Validate settings array + * + * @param array $settings Settings to validate. + * @return array Validation errors + */ + private function mosp_validate_settings( $settings ) { + $errors = array(); + + if ( $settings['cooldown_time'] < 0 || $settings['cooldown_time'] > 86400 ) { + $errors['cooldown_time'] = __( 'Cooldown time must be between 0 and 86400 seconds', 'miniorange-otp-verification' ); + } + + if ( $settings['max_attempts'] < 1 || $settings['max_attempts'] > 10 ) { + $errors['max_attempts'] = __( 'Max attempts must be between 1 and 10', 'miniorange-otp-verification' ); + } + + if ( $settings['block_time'] < 60 || $settings['block_time'] > 604800 ) { + $errors['block_time'] = __( 'Block time must be between 60 and 604800 seconds', 'miniorange-otp-verification' ); + } + if ( $settings['daily_limit'] < 1 || $settings['daily_limit'] > 1000 ) { + $errors['daily_limit'] = __( 'Daily limit must be between 1 and 1000', 'miniorange-otp-verification' ); + } + + if ( $settings['hourly_limit'] < 1 || $settings['hourly_limit'] > 100 ) { + $errors['hourly_limit'] = __( 'Hourly limit must be between 1 and 100', 'miniorange-otp-verification' ); + } + + if ( $settings['hourly_limit'] <= $settings['max_attempts'] ) { + $errors['hourly_limit'] = sprintf( + /* translators: %d: max attempts value */ + __( 'Hourly limit must be greater than max attempts per window (%d)', 'miniorange-otp-verification' ), + $settings['max_attempts'] + ); + } + + if ( $settings['daily_limit'] <= $settings['hourly_limit'] ) { + $errors['daily_limit'] = sprintf( + /* translators: %d: hourly limit value */ + __( 'Daily limit must be greater than hourly limit (%d)', 'miniorange-otp-verification' ), + $settings['hourly_limit'] + ); + } + + if ( ! $settings['track_phone'] && ! $settings['track_email'] && ! $settings['track_ip'] && ! $settings['track_browser'] ) { + $errors['tracking'] = __( 'At least one tracking method must be enabled', 'miniorange-otp-verification' ); + } + + foreach ( $settings['whitelist_ips'] as $ip ) { + if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { + $errors['whitelist_ips'] = sprintf( + /* translators: %s: invalid IP address */ + __( 'Invalid IP address: %s', 'miniorange-otp-verification' ), + $ip + ); + break; + } + } + return $errors; + } + + /** + * SECURITY ENHANCEMENT: AJAX handler for generating secure puzzles + * + * This method generates a new puzzle and stores it securely in the session, + * preventing client-side manipulation of puzzle data. + * + * @return void Sends JSON response. + */ + public function mosp_generate_puzzle_ajax() { + if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ), 'mo_osp_nonce' ) ) { + wp_send_json_error( array( 'message' => __( 'Security check failed', 'miniorange-otp-verification' ) ) ); + } + + $puzzle = MoPuzzleHelper::mosp_generate_secure_puzzle(); + + if ( ! $puzzle ) { + wp_send_json_error( array( 'message' => __( 'Failed to generate puzzle', 'miniorange-otp-verification' ) ) ); + } + + MoPuzzleHelper::mosp_store_puzzle_in_session( $puzzle['question'], $puzzle['answer'] ); + + $puzzle_image = MoPuzzleHelper::mosp_generate_puzzle_image( $puzzle['question'] ); + + $response = array( + 'question' => $puzzle['question'], + 'message' => __( 'Puzzle generated successfully', 'miniorange-otp-verification' ), + ); + + if ( $puzzle_image ) { + $response['image'] = $puzzle_image; + } + + wp_send_json_success( $response ); + } + + /** + * AJAX handler for checking if puzzle verification is required + * + * @return void Sends JSON response. + */ + public function mosp_check_puzzle_ajax() { + if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ), 'mo_osp_nonce' ) ) { + wp_send_json_error( array( 'message' => __( 'Security check failed', 'miniorange-otp-verification' ) ) ); + } + + $email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) ); + $phone = sanitize_text_field( wp_unslash( $_POST['phone'] ?? '' ) ); + $browser_id = sanitize_text_field( wp_unslash( $_POST['browser_id'] ?? '' ) ); + $ip = $this->handler->mosp_get_client_ip(); + + if ( ! empty( $phone ) ) { + $phone = MoUtility::process_phone_number( $phone ); + $digit_count = strlen( preg_replace( '/\D/', '', $phone ) ); + if ( $digit_count < 6 ) { + $phone = ''; + } + } + + if ( ! empty( $email ) ) { + MoPHPSessions::add_session_var( 'user_email', $email ); + } + if ( ! empty( $phone ) ) { + MoPHPSessions::add_session_var( 'phone_number_mo', $phone ); + } + $requires_puzzle = $this->handler->mosp_requires_puzzle_verification( $email, $phone, $ip, $browser_id ); + + wp_send_json_success( + array( + 'requires_puzzle' => $requires_puzzle, + 'message' => $requires_puzzle ? __( 'Puzzle verification required', 'miniorange-otp-verification' ) : __( 'No puzzle required', 'miniorange-otp-verification' ), + ) + ); + } + + /** + * AJAX handler for verifying puzzle completion + * SECURITY ENHANCEMENT: Uses session-stored puzzle data for validation + * + * @return void Sends JSON response. + */ + public function mosp_verify_puzzle_ajax() { + if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ), 'mo_osp_nonce' ) ) { + wp_send_json_error( array( 'message' => __( 'Security check failed', 'miniorange-otp-verification' ) ) ); + } + + $email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) ); + $phone = sanitize_text_field( wp_unslash( $_POST['phone'] ?? '' ) ); + $browser_id = sanitize_text_field( wp_unslash( $_POST['browser_id'] ?? '' ) ); + $ip = $this->handler->mosp_get_client_ip(); + + if ( ! empty( $phone ) ) { + $phone = MoUtility::process_phone_number( $phone ); + $digit_count = strlen( preg_replace( '/\D/', '', $phone ) ); + if ( $digit_count < 6 ) { + $phone = ''; + } + } + if ( empty( $email ) ) { + $email = MoPHPSessions::get_session_var( 'user_email' ); + } + $session_phone = MoPHPSessions::get_session_var( 'phone_number_mo' ); + if ( empty( $phone ) && ! empty( $session_phone ) ) { + $phone = $session_phone; + } + if ( ! empty( $phone ) ) { + $phone = MoUtility::process_phone_number( $phone ); + $digit_count = strlen( preg_replace( '/\D/', '', $phone ) ); + if ( $digit_count < 6 ) { + $phone = ''; + } + } + if ( ! empty( $session_phone ) ) { + $normalized_session_phone = MoUtility::process_phone_number( $session_phone ); + $session_digits = strlen( preg_replace( '/\D/', '', $normalized_session_phone ) ); + if ( $session_digits >= 6 ) { + $phone = $normalized_session_phone; + } + } + + $has_answer = array_key_exists( 'puzzle_answer', $_POST ); + $puzzle_answer = $has_answer ? intval( wp_unslash( $_POST['puzzle_answer'] ) ) : null; + + if ( ! $has_answer ) { + wp_send_json_error( array( 'message' => __( 'Puzzle answer is required', 'miniorange-otp-verification' ) ) ); + } + + if ( ! MoPuzzleHelper::mosp_has_puzzle_in_session() ) { + wp_send_json_error( array( 'message' => __( 'Puzzle session expired. Please refresh and try again.', 'miniorange-otp-verification' ) ) ); + } + + $is_valid = MoPuzzleHelper::mosp_validate_puzzle_answer_from_session( $puzzle_answer ); + + if ( ! $is_valid ) { + $puzzle_attempts_key = 'mo_osp_puzzle_attempts_' . md5( $ip . MoSecurityHelper::mosp_get_user_agent() ); + $incorrect_attempts = MoPHPSessions::get_session_var( $puzzle_attempts_key ); + + if ( false === $incorrect_attempts ) { + $incorrect_attempts = 0; + } + + ++$incorrect_attempts; + + $max_attempts_per_puzzle = 2; + + if ( $incorrect_attempts >= $max_attempts_per_puzzle ) { + MoPHPSessions::unset_session( $puzzle_attempts_key ); + + $new_puzzle = MoPuzzleHelper::mosp_generate_secure_puzzle(); + MoPuzzleHelper::mosp_store_puzzle_in_session( + $new_puzzle['question'], + $new_puzzle['answer'], + $ip, + MoSecurityHelper::mosp_get_user_agent() + ); + + $puzzle_image = MoPuzzleHelper::mosp_generate_puzzle_image( $new_puzzle['question'] ); + + $response_data = array( + 'message' => __( 'Incorrect puzzle answer. A new puzzle has been generated. Please solve it.', 'miniorange-otp-verification' ), + 'puzzle_reset' => true, + ); + + if ( $puzzle_image ) { + $response_data['puzzle_image'] = $puzzle_image; + } else { + $response_data['puzzle_question'] = $new_puzzle['question']; + } + + wp_send_json_error( $response_data ); + } else { + MoPHPSessions::add_session_var( $puzzle_attempts_key, $incorrect_attempts ); + $remaining_attempts = $max_attempts_per_puzzle - $incorrect_attempts; + + wp_send_json_error( + array( + 'message' => sprintf( + /* translators: %d: number of remaining attempts */ + __( 'Incorrect answer. Please try again. (%d attempt(s) remaining)', 'miniorange-otp-verification' ), + $remaining_attempts + ), + 'puzzle_reset' => false, + 'remaining_attempts' => $remaining_attempts, + ) + ); + } + } else { + $puzzle_attempts_key = 'mo_osp_puzzle_attempts_' . md5( $ip . MoSecurityHelper::mosp_get_user_agent() ); + MoPHPSessions::unset_session( $puzzle_attempts_key ); + } + + $this->handler->mosp_reset_immediate_spam_protection( $email, $phone ); + $requires_limit_puzzle = $this->handler->mosp_requires_limit_puzzle_verification( $email, $phone ); + if ( $requires_limit_puzzle ) { + $this->handler->mosp_mark_limit_puzzle_completed( $email, $phone ); + } else { + MoSecurityHelper::mosp_mark_puzzle_verification_complete( $email, $phone ); + } + + $verification_token = MoSecurityHelper::mosp_generate_puzzle_verification_token( $email, $phone ); + + wp_send_json_success( + array( + 'message' => __( 'Puzzle verified successfully', 'miniorange-otp-verification' ), + 'puzzle_cleared' => true, + 'verification_token' => $verification_token, + 'puzzle_nonce' => wp_create_nonce( 'mo_osp_puzzle_verify' ), + ) + ); + } + + /** + * SECURITY FIX: Calculate the correct answer for a puzzle question + * + * @param string $question The puzzle question. + * @return int|false The correct answer or false if invalid. + */ + private function calculate_puzzle_answer( $question ) { + if ( preg_match( '/(\d+)\s*([+\-×÷*\/])\s*(\d+)/', $question, $matches ) ) { + $a = intval( $matches[1] ); + $operator = $matches[2]; + $b = intval( $matches[3] ); + + switch ( $operator ) { + case '+': + return $a + $b; + case '-': + return $a - $b; + case '×': + case '*': + return $a * $b; + case '÷': + case '/': + return 0 !== $b ? intval( $a / $b ) : false; + default: + return false; + } + } + + if ( preg_match( '/(\d+)\s*([+\-])\s*(\d+)\s*([+\-])\s*(\d+)/', $question, $matches ) ) { + $a = intval( $matches[1] ); + $op1 = $matches[2]; + $b = intval( $matches[3] ); + $op2 = $matches[4]; + $c = intval( $matches[5] ); + + $result = $a; + $result = ( '+' === $op1 ) ? $result + $b : $result - $b; + $result = ( '+' === $op2 ) ? $result + $c : $result - $c; + + return $result; + } + + return false; + } + + /** + * AJAX handler for checking timer status with persistent state management. + * + * @return void Sends JSON response. + */ + public function mosp_check_timer_status_ajax() { + if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ), 'mo_osp_nonce' ) ) { + wp_send_json_error( array( 'message' => __( 'Security check failed', 'miniorange-otp-verification' ) ) ); + } + + $browser_id = isset( $_POST['mo_osp_browser_id'] ) ? sanitize_text_field( wp_unslash( $_POST['mo_osp_browser_id'] ) ) : ''; + $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; + $phone = isset( $_POST['phone'] ) ? MoUtility::process_phone_number( sanitize_text_field( wp_unslash( $_POST['phone'] ) ) ) : ''; + + $state = $this->mosp_get_current_user_state( $email, $phone, $browser_id ); + wp_send_json_success( $state ); + } + + /** + * Get current user state with accurate timer information. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param string $browser_id Browser ID. + * @return array Current user state. + */ + private function mosp_get_current_user_state( $email, $phone, $browser_id ) { + $settings = $this->storage->mosp_get_settings(); + $current_time = time(); + + $is_blocked = $this->handler->mosp_is_blocked( $email, $phone, $browser_id, 'timer_status' ); + + if ( $is_blocked ) { + $block_data = $this->handler->mosp_get_block_data( $email, $phone, $browser_id, 'timer_status' ); + $block_remaining = $block_data['remaining_time']; + $block_reason = $block_data['reason']; + + if ( $block_remaining > 0 ) { + return array( + 'blocked' => true, + 'remaining_time' => $block_remaining, + 'blocked_type' => $block_reason, + 'message' => MoMessages::showMessage( MoMessages::USER_IS_BLOCKED_AJAX ), + ); + } + } + + $requires_regular_puzzle = $this->handler->mosp_requires_puzzle_verification( $email, $phone, $this->handler->mosp_get_client_ip(), $browser_id ); + $requires_limit_puzzle = $this->handler->mosp_requires_limit_puzzle_verification( $email, $phone ); + $requires_puzzle = $requires_regular_puzzle || $requires_limit_puzzle; + + if ( $requires_puzzle ) { + return array( + 'puzzle_required' => true, + 'message' => 'Please complete the security verification to continue.', + ); + } + + $ip = $this->handler->mosp_get_client_ip(); + $is_ip_whitelisted = false; + if ( ! empty( $ip ) ) { + $is_ip_whitelisted = $this->handler->mosp_is_whitelisted( $ip, 'ip' ); + } + + $cooldown_remaining = $is_ip_whitelisted ? 0 : $this->mosp_get_cooldown_remaining_time( $email, $phone, $browser_id ); + + if ( $cooldown_remaining > 0 ) { + return array( + 'status' => 'cooldown', + 'cooldown' => true, + 'timer_active' => true, + 'remaining_time' => $cooldown_remaining, + 'message' => MoMessages::showMessage( MoMessages::LIMIT_OTP_SENT ), + ); + } + + return array( + 'status' => 'ready', + 'message' => 'Ready to send OTP', + ); + } + + /** + * Get remaining cooldown time for user. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param string $browser_id Browser ID. + * @return int Remaining cooldown time. + */ + private function mosp_get_cooldown_remaining_time( $email, $phone, $browser_id ) { + try { + $identifiers = $this->handler->mosp_get_all_identifiers( $email, $phone, $this->handler->mosp_get_client_ip(), $browser_id ); + $current_time = time(); + $settings = $this->storage->mosp_get_settings(); + $cooldown_time = isset( $settings['cooldown_time'] ) ? (int) $settings['cooldown_time'] : 60; + + if ( empty( $identifiers ) || ! is_array( $identifiers ) ) { + return 0; + } + + foreach ( $identifiers as $identifier ) { + if ( empty( $identifier ) ) { + continue; + } + + $key = $this->storage->mosp_hash_key( $identifier ); + $data = $this->storage->mosp_get_spam_data( $key ); + + if ( false === $data || ! isset( $data['attempts'] ) || ! is_array( $data['attempts'] ) ) { + continue; + } + + $attempts = $data['attempts']; + $attempt_count = count( $attempts ); + + if ( $attempt_count < 2 ) { + continue; + } + + $sorted_attempts = $attempts; + rsort( $sorted_attempts ); + + if ( ! isset( $sorted_attempts[0] ) || ! isset( $sorted_attempts[1] ) ) { + continue; + } + + $most_recent_attempt = (int) $sorted_attempts[0]; + $second_to_last_attempt = (int) $sorted_attempts[1]; + + $time_between_attempts = $most_recent_attempt - $second_to_last_attempt; + + if ( $time_between_attempts < $cooldown_time ) { + $cooldown_expires_at = $second_to_last_attempt + $cooldown_time; + $remaining = $cooldown_expires_at - $current_time; + + if ( $remaining > 0 ) { + return $remaining; + } + } + } + } catch ( Exception $e ) { + return 0; + } catch ( Error $e ) { + return 0; + } + + return 0; + } + + /** + * AJAX handler for checking puzzle requirement (separate from timer status) + * + * @return void Sends JSON response. + */ + public function mosp_check_puzzle_requirement_ajax() { + if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'mo_osp_nonce' ) ) { + wp_send_json_error( array( 'message' => __( 'Security check failed', 'miniorange-otp-verification' ) ) ); + } + + $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; + $phone = isset( $_POST['phone'] ) ? sanitize_text_field( wp_unslash( $_POST['phone'] ) ) : ''; + $browser_id = isset( $_POST['mo_osp_browser_id'] ) ? sanitize_text_field( wp_unslash( $_POST['mo_osp_browser_id'] ) ) : ''; + + $requires_regular_puzzle = $this->handler->mosp_requires_puzzle_verification( $email, $phone, $this->handler->mosp_get_client_ip(), $browser_id ); + $requires_limit_puzzle = $this->handler->mosp_requires_limit_puzzle_verification( $email, $phone ); + $requires_puzzle = $requires_regular_puzzle || $requires_limit_puzzle; + + wp_send_json_success( + array( + 'puzzle_required' => $requires_puzzle, + 'regular_puzzle' => $requires_regular_puzzle, + 'limit_puzzle' => $requires_limit_puzzle, + 'message' => $requires_puzzle ? MoMessages::showMessage( MoMessages::PLEASE_VALIDATE ) : __( 'No puzzle required', 'miniorange-otp-verification' ), + ) + ); + } + + /** + * AJAX handler for checking if user is blocked in popup (similar to resendcontrol) + * + * Checks if the user is blocked and sends a JSON response. + * This function calculates the remaining block time and cooldown for a user. + * If the user is still blocked or on cooldown, it returns the remaining time. + * + * @return void Sends a JSON response with the blocked status and remaining time. + */ + public function mosp_check_blocked_ajax() { + // phpcs:disable WordPress.Security.NonceVerification.Missing -- Public AJAX endpoint, nonce not required for read operations. + try { + $browser_id = isset( $_POST['mo_osp_browser_id'] ) ? sanitize_text_field( wp_unslash( $_POST['mo_osp_browser_id'] ) ) : ''; + $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; + $phone = isset( $_POST['phone'] ) ? sanitize_text_field( wp_unslash( $_POST['phone'] ) ) : ''; + + $is_blocked = $this->handler->mosp_is_blocked( $email, $phone, $browser_id, 'timer_status' ); + + if ( $is_blocked ) { + $block_data = $this->handler->mosp_get_block_data( $email, $phone, $browser_id, 'timer_status' ); + $block_remaining = isset( $block_data['remaining_time'] ) ? $block_data['remaining_time'] : 0; + $block_reason = isset( $block_data['reason'] ) ? $block_data['reason'] : ''; + + if ( $block_remaining > 0 ) { + wp_send_json( + array( + 'blocked' => true, + 'remaining_time' => $block_remaining, + 'blocked_type' => $block_reason, + 'message' => MoMessages::showMessage( MoMessages::USER_IS_BLOCKED_AJAX ), + ) + ); + return; + } + } + + $ip = $this->handler->mosp_get_client_ip(); + $is_ip_whitelisted = false; + if ( ! empty( $ip ) ) { + $is_ip_whitelisted = $this->handler->mosp_is_whitelisted( $ip, 'ip' ); + } + + $cooldown_remaining = $is_ip_whitelisted ? 0 : $this->mosp_get_cooldown_remaining_time( $email, $phone, $browser_id ); + + if ( $cooldown_remaining > 0 ) { + wp_send_json( + array( + 'cooldown' => true, + 'remaining_time' => $cooldown_remaining, + 'message' => MoMessages::showMessage( MoMessages::LIMIT_OTP_SENT ), + ) + ); + return; + } + + wp_send_json( + array( + 'blocked' => false, + 'cooldown' => false, + ) + ); + } catch ( Exception $e ) { + wp_send_json_error( + array( + 'message' => __( 'An error occurred while checking status.', 'miniorange-otp-verification' ), + 'error' => defined( 'WP_DEBUG' ) && WP_DEBUG ? $e->getMessage() : '', + ) + ); + } catch ( Error $e ) { + wp_send_json_error( + array( + 'message' => __( 'An error occurred while checking status.', 'miniorange-otp-verification' ), + 'error' => defined( 'WP_DEBUG' ) && WP_DEBUG ? $e->getMessage() : '', + ) + ); + } + } + + /** + * AJAX handler for unblocking user in popup (similar to resendcontrol) + * + * Unblocks a user if the block duration has expired and sends a JSON response. + * This function checks whether the user's block time has expired. If expired, + * it removes the block and sends a response indicating the user is unblocked. + * + * @return void Sends a JSON response with the blocked/unblocked status. + */ + public function mosp_unblock_user_ajax() { + // phpcs:disable WordPress.Security.NonceVerification.Missing -- Public AJAX endpoint, nonce not required for read operations. + $browser_id = isset( $_POST['mo_osp_browser_id'] ) ? sanitize_text_field( wp_unslash( $_POST['mo_osp_browser_id'] ) ) : ''; + $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; + $phone = isset( $_POST['phone'] ) ? sanitize_text_field( wp_unslash( $_POST['phone'] ) ) : ''; + + $is_blocked = $this->handler->mosp_is_blocked( $email, $phone, $browser_id, 'timer_status' ); + + if ( ! $is_blocked ) { + wp_send_json( array( 'unblocked' => true ) ); + return; + } + + $block_data = $this->handler->mosp_get_block_data( $email, $phone, $browser_id, 'timer_status' ); + $block_remaining = $block_data['remaining_time']; + + wp_send_json( + array( + 'blocked' => true, + 'remaining_time' => $block_remaining, + ) + ); + } + + /** + * AJAX handler for getting list of blocked users (admin only). + * + * @return void Sends JSON response with blocked users list. + */ + public function mosp_get_blocked_users_ajax() { + check_ajax_referer( 'mo_osp_admin_nonce', 'security' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( + array( 'message' => __( 'Insufficient permissions', 'miniorange-otp-verification' ) ), + 403 + ); + } + + $limit = isset( $_POST['limit'] ) ? absint( $_POST['limit'] ) : 100; + $offset = isset( $_POST['offset'] ) ? absint( $_POST['offset'] ) : 0; + + $result = $this->storage->mosp_get_all_blocked_users( $limit, $offset ); + + foreach ( $result['users'] as &$user ) { + $user['remaining_time_formatted'] = $this->mosp_format_time( $user['remaining_time'] ); + $user['block_reason_label'] = $this->mosp_get_block_reason_label( $user['block_reason'] ); + } + + wp_send_json_success( $result ); + } + + /** + * AJAX handler for unblocking user by hash (admin only). + * + * @return void Sends JSON response. + */ + public function mosp_unblock_user_by_hash_ajax() { + check_ajax_referer( 'mo_osp_admin_nonce', 'security' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( + array( 'message' => __( 'Insufficient permissions', 'miniorange-otp-verification' ) ), + 403 + ); + } + + $identifier_hash = isset( $_POST['identifier_hash'] ) ? sanitize_text_field( wp_unslash( $_POST['identifier_hash'] ) ) : ''; + + if ( empty( $identifier_hash ) ) { + wp_send_json_error( + array( 'message' => __( 'Invalid identifier hash', 'miniorange-otp-verification' ) ), + 400 + ); + } + + $result = $this->handler->mosp_unblock_user_by_hash( $identifier_hash ); + + if ( $result['success'] ) { + wp_send_json_success( array( 'message' => $result['message'] ) ); + } else { + wp_send_json_error( + array( 'message' => $result['message'] ), + 400 + ); + } + } + + /** + * AJAX handler: clear all blocked users / limits / puzzle flags (admin only). + * + * @return void + */ + public function mosp_clear_all_blocked_users_ajax() { + check_ajax_referer( 'mo_osp_admin_nonce', 'security' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( + array( 'message' => __( 'Insufficient permissions', 'miniorange-otp-verification' ) ), + 403 + ); + } + + $result = $this->handler->mosp_clear_all_blocked_data(); + + if ( ! $result['success'] ) { + wp_send_json_error( array( 'message' => $result['message'] ) ); + } + + wp_send_json_success( + array( + 'message' => $result['message'], + 'deleted' => $result['deleted'], + ) + ); + } + + /** + * AJAX handler for enabling/disabling addon (admin only). + * + * @return void Sends JSON response. + */ + public function mosp_toggle_addon_ajax() { + check_ajax_referer( 'mo_osp_admin_nonce', 'security' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( + array( 'message' => __( 'Insufficient permissions', 'miniorange-otp-verification' ) ), + 403 + ); + } + + $enabled = isset( $_POST['enabled'] ) ? absint( $_POST['enabled'] ) : 0; + $settings = $this->storage->mosp_get_settings(); + $settings['enabled'] = ( 1 === $enabled ); + + $result = $this->storage->mosp_update_settings( $settings ); + + if ( $result ) { + $message = $settings['enabled'] + ? __( 'Addon enabled.', 'miniorange-otp-verification' ) + : __( 'Addon disabled.', 'miniorange-otp-verification' ); + wp_send_json_success( array( 'message' => $message ) ); + } + + wp_send_json_error( + array( 'message' => __( 'Failed to update addon status.', 'miniorange-otp-verification' ) ), + 500 + ); + } + + /** + * Format time in seconds to human-readable format. + * + * @param int $seconds Time in seconds. + * @return string Formatted time string. + */ + private function mosp_format_time( $seconds ) { + if ( $seconds < 60 ) { + // translators: %d: Number of seconds. + return sprintf( _n( '%d second', '%d seconds', $seconds, 'miniorange-otp-verification' ), $seconds ); + } elseif ( $seconds < 3600 ) { + $minutes = floor( $seconds / 60 ); + $secs = $seconds % 60; + if ( $secs > 0 ) { + // translators: %d: Number of minutes. + $minutes_str = sprintf( _n( '%d minute', '%d minutes', $minutes, 'miniorange-otp-verification' ), $minutes ); + // translators: %d: Number of seconds. + $seconds_str = sprintf( _n( '%d second', '%d seconds', $secs, 'miniorange-otp-verification' ), $secs ); + return $minutes_str . ' ' . $seconds_str; + } + // translators: %d: Number of minutes. + return sprintf( _n( '%d minute', '%d minutes', $minutes, 'miniorange-otp-verification' ), $minutes ); + } else { + $hours = floor( $seconds / 3600 ); + $minutes = floor( ( $seconds % 3600 ) / 60 ); + if ( $minutes > 0 ) { + // translators: %d: Number of hours. + $hours_str = sprintf( _n( '%d hour', '%d hours', $hours, 'miniorange-otp-verification' ), $hours ); + // translators: %d: Number of minutes. + $minutes_str = sprintf( _n( '%d minute', '%d minutes', $minutes, 'miniorange-otp-verification' ), $minutes ); + return $hours_str . ' ' . $minutes_str; + } + // translators: %d: Number of hours. + return sprintf( _n( '%d hour', '%d hours', $hours, 'miniorange-otp-verification' ), $hours ); + } + } + + /** + * Get human-readable label for block reason. + * + * @param string $reason Block reason code. + * @return string Human-readable label. + */ + private function mosp_get_block_reason_label( $reason ) { + switch ( $reason ) { + case 'hourly_limit_exceeded': + return __( 'Hourly Limit Exceeded', 'miniorange-otp-verification' ); + case 'daily_limit_exceeded': + return __( 'Daily Limit Exceeded', 'miniorange-otp-verification' ); + case 'max_attempts_exceeded': + return __( 'Max Attempts Exceeded', 'miniorange-otp-verification' ); + case 'cooldown': + return __( 'Cooldown Period', 'miniorange-otp-verification' ); + default: + return __( 'Blocked', 'miniorange-otp-verification' ); + } + } + } +} @@ -1,712 +1,712 @@ -<?php -/** - * OTP Spam Integration Handler. - * - * @package otpspampreventer/handler - */ - -namespace OSP\Handler; - -if ( ! defined( 'ABSPATH' ) ) { - exit; -} - -use OSP\Traits\Instance; -use OSP\Handler\MoOtpSpamStorage; -use OSP\Handler\MoOtpSpamPreventerHandler; -use OSP\Helper\MoSecurityHelper; -use OSP\Helper\MoPuzzleHelper; -use OTP\Helper\FormList; -use OTP\Helper\MoPHPSessions; -use OTP\Helper\MoMessages; - -if ( ! class_exists( 'MoOtpSpamIntegration' ) ) { - /** - * Integrates spam prevention with the main OTP plugin - */ - class MoOtpSpamIntegration { - - use Instance; - - /** - * Storage handler - * - * @var MoOtpSpamStorage - */ - private $storage; - - /** - * Spam prevention handler - * - * @var MoOtpSpamPreventerHandler - */ - private $handler; - - /** - * Flag to track if hooks are initialized - * - * @var bool - */ - private $hooks_initialized = false; - - /** - * Initialize the integration - */ - protected function __construct() { - $this->storage = MoOtpSpamStorage::instance(); - $this->handler = MoOtpSpamPreventerHandler::instance(); - - if ( ! $this->mosp_is_addon_enabled() ) { - return; - } - - $this->init_essential_hooks(); - - add_action( 'wp_enqueue_scripts', array( $this, 'mosp_enqueue_frontend_scripts' ) ); - - add_action( 'admin_enqueue_scripts', array( $this, 'mosp_enqueue_admin_scripts' ) ); - - add_action( 'wp_footer', array( $this, 'mosp_add_puzzle_popup_to_frontend' ) ); - } - - /** - * Initialize essential hooks that don't require database access - */ - private function init_essential_hooks() { - add_filter( 'mo_osp_get_cooldown_time', array( $this, 'mosp_filter_get_cooldown_time' ), 10, 1 ); - - add_action( 'mo_osp_mosp_check_spam_before_otp_send', array( $this, 'mosp_check_spam_before_otp_send' ), 1, 5 ); - - add_action( 'mo_generate_or_resend_otp', array( $this, 'mosp_check_spam_before_otp_send' ), 1, 5 ); - - add_action( 'mo_include_js', array( $this, 'mosp_include_timer_js' ) ); - } - - /** - * Check for spam before OTP is sent (CENTRALIZED RATE LIMITING). - * - * This method is called by FormActionHandler::handleOTPAction via mo_osp_mosp_check_spam_before_otp_send action. - * It's called BEFORE OTP is sent, alongside ResendControl checks, in ONE central location. - * This ensures ALL spam prevention (rate limits, puzzles, cooldowns) is enforced consistently. - * - * @param string $user_login Username or identifier. - * @param string $user_email Email address. - * @param string $phone_number Phone number. - * @param string $otp_type Type of OTP (email/sms). - * @param string $from_both Whether both email and phone are enabled. - * @return void Exits early if blocked or puzzle required. - * - * @phpcs:disable WordPress.Security.NonceVerification.Missing -- Called from OTP generation hook, no nonce available - */ - public function mosp_check_spam_before_otp_send( $user_login, $user_email, $phone_number, $otp_type, $from_both ) { - if ( ! $this->mosp_is_addon_enabled() ) { - return; - } - - $otp_type = strtolower( trim( (string) $otp_type ) ); - switch ( $otp_type ) { - case \OTP\Objects\VerificationType::EMAIL: - $phone_number = ''; - break; - case \OTP\Objects\VerificationType::PHONE: - $user_email = ''; - break; - case \OTP\Objects\VerificationType::BOTH: - // Keep both identifiers. - break; - } - - if ( ! empty( $phone_number ) && strpos( $phone_number, '@' ) !== false ) { - $phone_number = ''; - } - - if ( ! empty( $user_email ) ) { - MoPHPSessions::add_session_var( 'user_email', $user_email ); - } - if ( ! empty( $phone_number ) ) { - MoPHPSessions::add_session_var( 'phone_number_mo', $phone_number ); - } - - static $attempt_recorded = false; - $request_key = $user_email . '|' . $phone_number . '|' . time(); - static $last_request_key = ''; - - if ( $last_request_key === $request_key && $attempt_recorded ) { - return; - } - - $last_request_key = $request_key; - - $browser_id = isset( $_POST['mo_osp_browser_id'] ) ? sanitize_text_field( wp_unslash( $_POST['mo_osp_browser_id'] ) ) : ''; - - $puzzle_already_verified = MoSecurityHelper::mosp_is_puzzle_verification_valid( $user_email, $phone_number ); - - if ( $puzzle_already_verified ) { - $this->handler->mosp_record_attempt_for_identifiers( $user_email, $phone_number, $browser_id ); - $attempt_recorded = true; - return; - } - - $is_blocked = $this->handler->mosp_is_blocked( $user_email, $phone_number, $browser_id, 'otp_send' ); - - $current_block_data = $this->handler->mosp_get_block_data( $user_email, $phone_number, $browser_id, 'otp_send' ); - $current_block_remaining_time = $current_block_data['remaining_time']; - $current_block_reason = $current_block_data['reason']; - - $ip = $this->handler->mosp_get_client_ip(); - $is_ip_whitelisted = false; - if ( ! empty( $ip ) ) { - $is_ip_whitelisted = $this->handler->mosp_is_whitelisted( $ip, 'ip' ); - - } - - $would_be_blocked_reason = ''; - $would_be_blocked_remaining_time = 0; - - if ( ! $is_blocked && $current_block_remaining_time <= 0 && ! $is_ip_whitelisted ) { - $would_be_blocked_result = $this->handler->mosp_would_be_blocked_after_attempt_with_details( $user_email, $phone_number, $browser_id ); - if ( $would_be_blocked_result['would_be_blocked'] ) { - $is_blocked = true; - $would_be_blocked_reason = $would_be_blocked_result['reason']; - $would_be_blocked_remaining_time = $would_be_blocked_result['remaining_time']; - - $this->handler->mosp_store_block_for_identifiers( $user_email, $phone_number, $browser_id, $would_be_blocked_reason, $would_be_blocked_remaining_time ); - } - } elseif ( ( $is_blocked || $current_block_remaining_time > 0 ) && ! $is_ip_whitelisted ) { - $is_blocked = true; - $would_be_blocked_reason = $current_block_reason; - $would_be_blocked_remaining_time = $current_block_remaining_time; - } elseif ( $is_ip_whitelisted ) { - $is_blocked = false; - $would_be_blocked_reason = ''; - $would_be_blocked_remaining_time = 0; - } - $requires_puzzle = false; - - if ( ! $is_blocked ) { - - $requires_puzzle = $this->handler->mosp_requires_puzzle_verification( $user_email, $phone_number, $this->handler->mosp_get_client_ip(), $browser_id ); - - $requires_limit_puzzle = $this->handler->mosp_requires_limit_puzzle_verification( $user_email, $phone_number ); - - $requires_puzzle = $requires_puzzle || $requires_limit_puzzle; - } - - if ( $requires_puzzle ) { - $is_ajax_form = apply_filters( 'is_ajax_form', false ); - - if ( $is_ajax_form || 'ajax_phone' === $user_login ) { - wp_send_json( - array( - 'result' => 'puzzle_required', - 'message' => __( 'Please complete the security verification to continue.', 'miniorange-otp-verification' ), - 'puzzle_required' => true, - 'authType' => 'PUZZLE_REQUIRED', - ) - ); - } else { - - $puzzle_email = ! empty( $user_email ) ? $user_email : 'puzzle@temp.local'; - $puzzle_phone = ! empty( $phone_number ) ? $phone_number : null; - - miniorange_site_otp_validation_form( $user_login, $puzzle_email, $puzzle_phone, __( 'Please complete the security verification to continue.', 'miniorange-otp-verification' ), $otp_type, $from_both ); - exit; - } - } elseif ( ! $is_blocked ) { - $this->handler->mosp_record_attempt_for_identifiers( $user_email, $phone_number, $browser_id ); - $attempt_recorded = true; - - $is_blocked_after_attempt = $this->handler->mosp_is_blocked( $user_email, $phone_number, $browser_id, 'otp_send' ); - if ( $is_blocked_after_attempt ) { - $is_blocked = true; - } - } - - if ( $is_blocked ) { - if ( $would_be_blocked_remaining_time > 0 ) { - $remaining_time = $would_be_blocked_remaining_time; - $block_reason = $would_be_blocked_reason; - } elseif ( $current_block_remaining_time > 0 ) { - $remaining_time = $current_block_remaining_time; - $block_reason = $current_block_reason; - } else { - // Fallback: re-check block status right before displaying error (block may have expired since initial check). - $block_data = $this->handler->mosp_get_block_data( $user_email, $phone_number, $browser_id, 'otp_send' ); - $remaining_time = $block_data['remaining_time']; - $block_reason = $block_data['reason']; - } - - if ( $would_be_blocked_remaining_time > 0 ) { - $remaining_time = $would_be_blocked_remaining_time; - $block_reason = $would_be_blocked_reason; - } elseif ( $remaining_time <= 0 ) { - $requires_puzzle = $this->handler->mosp_requires_puzzle_verification( $user_email, $phone_number, $this->handler->mosp_get_client_ip(), $browser_id ); - $requires_limit_puzzle = $this->handler->mosp_requires_limit_puzzle_verification( $user_email, $phone_number ); - $requires_puzzle = $requires_puzzle || $requires_limit_puzzle; - - if ( ! $requires_puzzle && ! $attempt_recorded ) { - $this->handler->mosp_record_attempt_for_identifiers( $user_email, $phone_number, $browser_id ); - $attempt_recorded = true; - return; - } - - if ( $requires_puzzle ) { - $is_ajax_form = apply_filters( 'is_ajax_form', false ); - if ( $is_ajax_form || 'ajax_phone' === $user_login ) { - wp_send_json( - array( - 'result' => 'puzzle_required', - 'message' => __( 'Please complete the security verification to continue.', 'miniorange-otp-verification' ), - 'puzzle_required' => true, - 'authType' => 'PUZZLE_REQUIRED', - ) - ); - } else { - $puzzle_email = ! empty( $user_email ) ? $user_email : 'puzzle@temp.local'; - $puzzle_phone = ! empty( $phone_number ) ? $phone_number : null; - miniorange_site_otp_validation_form( $user_login, $puzzle_email, $puzzle_phone, __( 'Please complete the security verification to continue.', 'miniorange-otp-verification' ), $otp_type, $from_both ); - } - exit; - } - - // Block window is over (0s left) but stale $is_blocked — allow OTP send instead of a bogus 00:00 error. - return; - } - - $message = $this->handler->mosp_get_block_message_with_timer( $remaining_time ); - $is_ajax_form = apply_filters( 'is_ajax_form', false ); - - if ( $is_ajax_form || 'ajax_phone' === $user_login ) { - wp_send_json( - array( - 'result' => 'error', - 'message' => $message, - 'blocked' => true, - 'remaining_time' => $remaining_time, - 'blocked_type' => $block_reason, - 'authType' => 'BLOCKED', - 'status' => 'BLOCKED', - ) - ); - exit; - } else { - miniorange_site_otp_validation_form( null, null, null, $message, $otp_type, $from_both ); - exit; - } - } - } - - /** - * Enqueue frontend scripts. - * - * @return void - */ - public function mosp_enqueue_frontend_scripts() { - if ( is_admin() ) { - return; - } - - if ( ! $this->mosp_is_addon_enabled() ) { - return; - } - - if ( ! $this->is_otp_verification_active_on_page() ) { - return; - } - - $settings = $this->storage->mosp_get_settings(); - - wp_enqueue_script( - 'mo-osp-frontend', - MO_OSP_URL . 'includes/js/spam-preventer.js', - array( 'jquery' ), - '1.1.0', - true - ); - - wp_enqueue_script( - 'mo-osp-puzzle-system', - MO_OSP_URL . 'includes/js/puzzle-system.js', - array( 'jquery', 'mo-osp-frontend' ), - '1.0.0', - true - ); - - wp_enqueue_style( - 'mo-osp-puzzle-css', - MO_OSP_URL . 'includes/css/mo-admin.css', - array(), - '1.0.5' - ); - - wp_localize_script( - 'mo-osp-frontend', - 'mo_osp_ajax', - array( - 'ajax_url' => admin_url( 'admin-ajax.php' ), - 'nonce' => wp_create_nonce( 'mo_osp_nonce' ), - 'loading_text' => __( 'Checking...', 'miniorange-otp-verification' ), - 'timer_time' => $settings['cooldown_time'], - ) - ); - } - - /** - * Enqueue admin scripts. - * - * @param string $hook_suffix Current admin page. - * @return void - */ - public function mosp_enqueue_admin_scripts( $hook_suffix ) { - if ( strpos( $hook_suffix, 'mo_otp_verification' ) === false ) { - return; - } - - $mo_osp_admin_js = MO_OSP_DIR . 'includes/js/spam-preventer-admin.js'; - wp_enqueue_script( - 'mo-osp-admin', - MO_OSP_URL . 'includes/js/spam-preventer-admin.js', - array( 'jquery' ), - file_exists( $mo_osp_admin_js ) ? (string) filemtime( $mo_osp_admin_js ) : '1.0.1', - true - ); - - wp_localize_script( - 'mo-osp-admin', - 'mo_osp_admin_ajax', - array( - 'ajax_url' => admin_url( 'admin-ajax.php' ), - 'nonce' => wp_create_nonce( 'mo_osp_admin_nonce' ), - ) - ); - } - - /** - * Include timer JavaScript for pop-up forms. - * This is called via mo_include_js action when popup is rendered. - */ - public function mosp_include_timer_js() { - if ( ! $this->mosp_is_addon_enabled() ) { - return; - } - - $settings = $this->storage->mosp_get_settings(); - - $email = MoPHPSessions::get_session_var( 'user_email' ); - $phone = MoPHPSessions::get_session_var( 'phone_number_mo' ); - // phpcs:disable WordPress.Security.NonceVerification.Missing - $browser_id = isset( $_POST['mo_osp_browser_id'] ) ? sanitize_text_field( wp_unslash( $_POST['mo_osp_browser_id'] ) ) : ''; - $ip = $this->handler->mosp_get_client_ip(); - $is_ip_whitelisted = false; - if ( ! empty( $ip ) ) { - $is_ip_whitelisted = $this->handler->mosp_is_whitelisted( $ip, 'ip' ); - } - - $cooldown_remaining = $is_ip_whitelisted ? 0 : $this->mosp_get_cooldown_remaining_time( $email, $phone, $browser_id ); - $is_blocked = $this->handler->mosp_is_blocked( $email, $phone, $browser_id, 'popup_render' ); - - /* - * mo_include_js runs mid-document (during popup HTML). Bundled scripts end with IIFEs like })(jQuery); - * Inline code below also invokes jQuery immediately. Force core jQuery to print first so it is defined. - */ - wp_enqueue_script( 'jquery' ); - wp_print_scripts( 'jquery' ); - - wp_register_style( 'mo-osp-puzzle-css-popup', MO_OSP_URL . 'includes/css/mo-admin.css', array(), '1.0.5', 'all' ); - wp_print_styles( 'mo-osp-puzzle-css-popup' ); - - wp_register_script( 'mo-osp-timer', MO_OSP_URL . 'includes/js/spam-preventer.js', array( 'jquery' ), '1.0.0', false ); - wp_localize_script( - 'mo-osp-timer', - 'mo_osp_timer', - array( - 'timer_time' => $settings['cooldown_time'], - ) - ); - wp_print_scripts( 'mo-osp-timer' ); - - wp_register_script( 'mo-osp-puzzle-system', MO_OSP_URL . 'includes/js/puzzle-system.js', array( 'jquery', 'mo-osp-timer' ), '1.0.0', false ); - wp_localize_script( - 'mo-osp-puzzle-system', - 'mo_osp_ajax', - array( - 'ajax_url' => admin_url( 'admin-ajax.php' ), - 'nonce' => wp_create_nonce( 'mo_osp_nonce' ), - 'timer_time' => $settings['cooldown_time'], - 'block_time' => $settings['block_time'], - 'enable_logs' => defined( 'WP_DEBUG' ) && WP_DEBUG, - ) - ); - wp_print_scripts( 'mo-osp-puzzle-system' ); - - wp_register_script( 'mo-osp-popup-timer', MO_OSP_URL . 'includes/js/popup-timer.js', array( 'jquery', 'mo-osp-timer', 'mo-osp-puzzle-system' ), '1.0.0', false ); - wp_localize_script( - 'mo-osp-popup-timer', - 'mo_osp_popup_timer', - array( - 'ajax_url' => admin_url( 'admin-ajax.php' ), - 'nonce' => wp_create_nonce( 'mo_osp_nonce' ), - 'timer_time' => $settings['cooldown_time'], - 'block_time' => $settings['block_time'], - 'limit_otp_sent' => MoMessages::showMessage( MoMessages::LIMIT_OTP_SENT ), - 'user_blocked' => MoMessages::showMessage( MoMessages::USER_IS_BLOCKED_AJAX ), - 'error_otp_verify' => MoMessages::showMessage( MoMessages::ERROR_OTP_VERIFY ), - 'initial_cooldown_time' => $cooldown_remaining, - 'initial_blocked' => $is_blocked, - 'puzzle_required_text' => __( 'Please complete the security verification to continue.', 'miniorange-otp-verification' ), - ) - ); - wp_print_scripts( 'mo-osp-popup-timer' ); - - echo '<div id="mo-osp-puzzle-popup-outer-div" style="display:none;">'; - MoPuzzleHelper::mosp_render_puzzle_popup(); - echo '</div>'; - $puzzle_message = __( 'Please complete the security verification to continue.', 'miniorange-otp-verification' ); - ?> - <script type="text/javascript"> - (function() { - function moOspRunInlinePuzzle($) { - 'use strict'; - - if (window.mo_osp_inline_puzzle_check_executed) { - return; - } - window.mo_osp_inline_puzzle_check_executed = true; - - function checkAndShowPuzzle() { - if (window.mo_osp_puzzle_shown) { - return; - } - - var $popupBody = $('.mo_customer_validation-modal-body'); - var $firstDiv = $popupBody.children('div').first(); - var messageText = ''; - - if ($firstDiv.length > 0) { - messageText = $firstDiv.text().trim(); - } - if (!messageText && $popupBody.length > 0) { - messageText = $popupBody.text().trim(); - } - - var puzzleText = '<?php echo esc_js( $puzzle_message ); ?>'; - - if (messageText && messageText.toLowerCase().includes(puzzleText.toLowerCase())) { - - window.mo_osp_puzzle_shown = true; - - $('#mo_site_otp_form').hide(); - $('.mo_customer_validation-modal').hide(); - $('.mo-modal-backdrop').hide(); - - if ($('#mo-osp-puzzle-overlay').length === 0) { - return; - } - - var $puzzleOverlay = $('#mo-osp-puzzle-overlay'); - $puzzleOverlay.css('z-index', '100001'); - - $('#mo-osp-puzzle-popup-outer-div').show().css('z-index', '100002'); - $puzzleOverlay.removeClass('mo-osp-hidden'); - - window.MO_OSP_Puzzle_onPopupSuccess = function() { - if (typeof window.MO_OSP_Puzzle !== 'undefined' && window.MO_OSP_Puzzle.closePuzzle) { - window.MO_OSP_Puzzle.closePuzzle(); - } else { - jQuery('#mo-osp-puzzle-overlay').addClass('mo-osp-hidden'); - jQuery('#mo-osp-puzzle-popup-outer-div').hide(); - } - - var resendForm = document.getElementById('verification_resend_otp_form'); - if (resendForm) { - if (!resendForm.querySelector('input[name="puzzle_verified"]')) { - var puzzleVerifiedInput = document.createElement('input'); - puzzleVerifiedInput.type = 'hidden'; - puzzleVerifiedInput.name = 'puzzle_verified'; - puzzleVerifiedInput.value = 'true'; - resendForm.appendChild(puzzleVerifiedInput); - } - resendForm.submit(); - } else { - sessionStorage.setItem('mo_osp_puzzle_completed', 'true'); - window.location.reload(); - } - }; - - if (typeof window.MO_OSP_Puzzle !== 'undefined') { - if (typeof window.MO_OSP_Puzzle.init === 'function' && !window.MO_OSP_Puzzle.initialized) { - window.MO_OSP_Puzzle.init(); - window.MO_OSP_Puzzle.initialized = true; - } - window.MO_OSP_Puzzle.showPuzzle({}); - } else { - setTimeout(function() { - if (typeof window.MO_OSP_Puzzle !== 'undefined') { - if (typeof window.MO_OSP_Puzzle.init === 'function' && !window.MO_OSP_Puzzle.initialized) { - window.MO_OSP_Puzzle.init(); - window.MO_OSP_Puzzle.initialized = true; - } - window.MO_OSP_Puzzle.showPuzzle({}); - } else { - console.error('MO_OSP_Puzzle still not available after wait'); - } - }, 500); - } - } - } - - var checkExecuted = false; - function runCheckOnce() { - if (checkExecuted) { - return; - } - checkExecuted = true; - checkAndShowPuzzle(); - } - - if (document.readyState === 'loading') { - $(document).ready(function() { - setTimeout(runCheckOnce, 300); - }); - } else { - setTimeout(runCheckOnce, 300); - } - } - function moOspTryInlinePuzzle() { - var jq = window.jQuery; - if (typeof jq === 'undefined') { - return false; - } - moOspRunInlinePuzzle(jq); - return true; - } - if (!moOspTryInlinePuzzle()) { - var moOspInlineIv = setInterval(function () { - if (moOspTryInlinePuzzle()) { - clearInterval(moOspInlineIv); - } - }, 30); - setTimeout(function () { - clearInterval(moOspInlineIv); - }, 15000); - } - })(); - </script> - <?php - } - - /** - * Add puzzle popup HTML to frontend. - */ - public function mosp_add_puzzle_popup_to_frontend() { - if ( is_admin() ) { - return; - } - - if ( ! $this->mosp_is_addon_enabled() ) { - return; - } - - if ( ! $this->is_otp_verification_active_on_page() ) { - return; - } - - echo '<div id="mo-osp-puzzle-popup-outer-div" style="display:none;">'; - MoPuzzleHelper::mosp_render_puzzle_popup(); - echo '</div>'; - } - - /** - * Check if OTP verification is active on the current page. - * - * @return bool True if any form has OTP verification enabled - */ - private function is_otp_verification_active_on_page() { - $form_list = FormList::instance(); - $all_forms = $form_list->get_list(); - - foreach ( $all_forms as $form_handler ) { - if ( $form_handler && method_exists( $form_handler, 'is_form_enabled' ) ) { - if ( $form_handler->is_form_enabled() ) { - return true; - } - } - } - - $otp_verification_options = array( - 'cf_submit_id', - 'wc_default_enable', - 'wp_default_enable', - 'wp_login_enable', - 'wc_checkout_enable', - 'bp_registration_enable', - 'um_default_enable', - 'pmpro_default_enable', - ); - - foreach ( $otp_verification_options as $option ) { - if ( get_mo_option( $option ) ) { - return true; - } - } - - return false; - } - - /** - * Provide addon cooldown time to host plugin for server-side formatting. - * - * @param int $default_value Default fallback value. - * @return int seconds - */ - public function mosp_filter_get_cooldown_time( $default_value = 60 ) { - if ( ! $this->mosp_is_addon_enabled() ) { - return (int) $default_value; - } - - $settings = $this->storage->mosp_get_settings(); - return isset( $settings['cooldown_time'] ) ? (int) $settings['cooldown_time'] : (int) $default_value; - } - - /** - * Check if spam preventer addon is enabled in settings. - * - * @return bool - */ - private function mosp_is_addon_enabled() { - $settings = $this->storage->mosp_get_settings(); - return ! empty( $settings['enabled'] ); - } - - /** - * Get remaining cooldown time for user. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param string $browser_id Browser ID. - * @return int Remaining cooldown time in seconds. - */ - private function mosp_get_cooldown_remaining_time( $email, $phone, $browser_id ) { - $identifiers = $this->handler->mosp_get_all_identifiers( $email, $phone, $this->handler->mosp_get_client_ip(), $browser_id ); - $current_time = time(); - $settings = $this->storage->mosp_get_settings(); - $cooldown_time = $settings['cooldown_time']; - - foreach ( $identifiers as $identifier ) { - $key = $this->storage->mosp_hash_key( $identifier ); - $data = $this->storage->mosp_get_spam_data( $key ); - - if ( false !== $data && isset( $data['last_attempt'] ) && $data['last_attempt'] > 0 ) { - $time_since_last = $current_time - $data['last_attempt']; - $remaining = $cooldown_time - $time_since_last; - - if ( $remaining > 0 ) { - return $remaining; - } - } - } - - return 0; - } - } -} +<?php +/** + * OTP Spam Integration Handler. + * + * @package otpspampreventer/handler + */ + +namespace OSP\Handler; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +use OSP\Traits\Instance; +use OSP\Handler\MoOtpSpamStorage; +use OSP\Handler\MoOtpSpamPreventerHandler; +use OSP\Helper\MoSecurityHelper; +use OSP\Helper\MoPuzzleHelper; +use OTP\Helper\FormList; +use OTP\Helper\MoPHPSessions; +use OTP\Helper\MoMessages; + +if ( ! class_exists( 'MoOtpSpamIntegration' ) ) { + /** + * Integrates spam prevention with the main OTP plugin + */ + class MoOtpSpamIntegration { + + use Instance; + + /** + * Storage handler + * + * @var MoOtpSpamStorage + */ + private $storage; + + /** + * Spam prevention handler + * + * @var MoOtpSpamPreventerHandler + */ + private $handler; + + /** + * Flag to track if hooks are initialized + * + * @var bool + */ + private $hooks_initialized = false; + + /** + * Initialize the integration + */ + protected function __construct() { + $this->storage = MoOtpSpamStorage::instance(); + $this->handler = MoOtpSpamPreventerHandler::instance(); + + if ( ! $this->mosp_is_addon_enabled() ) { + return; + } + + $this->init_essential_hooks(); + + add_action( 'wp_enqueue_scripts', array( $this, 'mosp_enqueue_frontend_scripts' ) ); + + add_action( 'admin_enqueue_scripts', array( $this, 'mosp_enqueue_admin_scripts' ) ); + + add_action( 'wp_footer', array( $this, 'mosp_add_puzzle_popup_to_frontend' ) ); + } + + /** + * Initialize essential hooks that don't require database access + */ + private function init_essential_hooks() { + add_filter( 'mo_osp_get_cooldown_time', array( $this, 'mosp_filter_get_cooldown_time' ), 10, 1 ); + + add_action( 'mo_osp_mosp_check_spam_before_otp_send', array( $this, 'mosp_check_spam_before_otp_send' ), 1, 5 ); + + add_action( 'mo_generate_or_resend_otp', array( $this, 'mosp_check_spam_before_otp_send' ), 1, 5 ); + + add_action( 'mo_include_js', array( $this, 'mosp_include_timer_js' ) ); + } + + /** + * Check for spam before OTP is sent (CENTRALIZED RATE LIMITING). + * + * This method is called by FormActionHandler::handleOTPAction via mo_osp_mosp_check_spam_before_otp_send action. + * It's called BEFORE OTP is sent, alongside ResendControl checks, in ONE central location. + * This ensures ALL spam prevention (rate limits, puzzles, cooldowns) is enforced consistently. + * + * @param string $user_login Username or identifier. + * @param string $user_email Email address. + * @param string $phone_number Phone number. + * @param string $otp_type Type of OTP (email/sms). + * @param string $from_both Whether both email and phone are enabled. + * @return void Exits early if blocked or puzzle required. + * + * @phpcs:disable WordPress.Security.NonceVerification.Missing -- Called from OTP generation hook, no nonce available + */ + public function mosp_check_spam_before_otp_send( $user_login, $user_email, $phone_number, $otp_type, $from_both ) { + if ( ! $this->mosp_is_addon_enabled() ) { + return; + } + + $otp_type = strtolower( trim( (string) $otp_type ) ); + switch ( $otp_type ) { + case \OTP\Objects\VerificationType::EMAIL: + $phone_number = ''; + break; + case \OTP\Objects\VerificationType::PHONE: + $user_email = ''; + break; + case \OTP\Objects\VerificationType::BOTH: + // Keep both identifiers. + break; + } + + if ( ! empty( $phone_number ) && strpos( $phone_number, '@' ) !== false ) { + $phone_number = ''; + } + + if ( ! empty( $user_email ) ) { + MoPHPSessions::add_session_var( 'user_email', $user_email ); + } + if ( ! empty( $phone_number ) ) { + MoPHPSessions::add_session_var( 'phone_number_mo', $phone_number ); + } + + static $attempt_recorded = false; + $request_key = $user_email . '|' . $phone_number . '|' . time(); + static $last_request_key = ''; + + if ( $last_request_key === $request_key && $attempt_recorded ) { + return; + } + + $last_request_key = $request_key; + + $browser_id = isset( $_POST['mo_osp_browser_id'] ) ? sanitize_text_field( wp_unslash( $_POST['mo_osp_browser_id'] ) ) : ''; + + $puzzle_already_verified = MoSecurityHelper::mosp_is_puzzle_verification_valid( $user_email, $phone_number ); + + if ( $puzzle_already_verified ) { + $this->handler->mosp_record_attempt_for_identifiers( $user_email, $phone_number, $browser_id ); + $attempt_recorded = true; + return; + } + + $is_blocked = $this->handler->mosp_is_blocked( $user_email, $phone_number, $browser_id, 'otp_send' ); + + $current_block_data = $this->handler->mosp_get_block_data( $user_email, $phone_number, $browser_id, 'otp_send' ); + $current_block_remaining_time = $current_block_data['remaining_time']; + $current_block_reason = $current_block_data['reason']; + + $ip = $this->handler->mosp_get_client_ip(); + $is_ip_whitelisted = false; + if ( ! empty( $ip ) ) { + $is_ip_whitelisted = $this->handler->mosp_is_whitelisted( $ip, 'ip' ); + + } + + $would_be_blocked_reason = ''; + $would_be_blocked_remaining_time = 0; + + if ( ! $is_blocked && $current_block_remaining_time <= 0 && ! $is_ip_whitelisted ) { + $would_be_blocked_result = $this->handler->mosp_would_be_blocked_after_attempt_with_details( $user_email, $phone_number, $browser_id ); + if ( $would_be_blocked_result['would_be_blocked'] ) { + $is_blocked = true; + $would_be_blocked_reason = $would_be_blocked_result['reason']; + $would_be_blocked_remaining_time = $would_be_blocked_result['remaining_time']; + + $this->handler->mosp_store_block_for_identifiers( $user_email, $phone_number, $browser_id, $would_be_blocked_reason, $would_be_blocked_remaining_time ); + } + } elseif ( ( $is_blocked || $current_block_remaining_time > 0 ) && ! $is_ip_whitelisted ) { + $is_blocked = true; + $would_be_blocked_reason = $current_block_reason; + $would_be_blocked_remaining_time = $current_block_remaining_time; + } elseif ( $is_ip_whitelisted ) { + $is_blocked = false; + $would_be_blocked_reason = ''; + $would_be_blocked_remaining_time = 0; + } + $requires_puzzle = false; + + if ( ! $is_blocked ) { + + $requires_puzzle = $this->handler->mosp_requires_puzzle_verification( $user_email, $phone_number, $this->handler->mosp_get_client_ip(), $browser_id ); + + $requires_limit_puzzle = $this->handler->mosp_requires_limit_puzzle_verification( $user_email, $phone_number ); + + $requires_puzzle = $requires_puzzle || $requires_limit_puzzle; + } + + if ( $requires_puzzle ) { + $is_ajax_form = apply_filters( 'is_ajax_form', false ); + + if ( $is_ajax_form || 'ajax_phone' === $user_login ) { + wp_send_json( + array( + 'result' => 'puzzle_required', + 'message' => __( 'Please complete the security verification to continue.', 'miniorange-otp-verification' ), + 'puzzle_required' => true, + 'authType' => 'PUZZLE_REQUIRED', + ) + ); + } else { + + $puzzle_email = ! empty( $user_email ) ? $user_email : 'puzzle@temp.local'; + $puzzle_phone = ! empty( $phone_number ) ? $phone_number : null; + + miniorange_site_otp_validation_form( $user_login, $puzzle_email, $puzzle_phone, __( 'Please complete the security verification to continue.', 'miniorange-otp-verification' ), $otp_type, $from_both ); + exit; + } + } elseif ( ! $is_blocked ) { + $this->handler->mosp_record_attempt_for_identifiers( $user_email, $phone_number, $browser_id ); + $attempt_recorded = true; + + $is_blocked_after_attempt = $this->handler->mosp_is_blocked( $user_email, $phone_number, $browser_id, 'otp_send' ); + if ( $is_blocked_after_attempt ) { + $is_blocked = true; + } + } + + if ( $is_blocked ) { + if ( $would_be_blocked_remaining_time > 0 ) { + $remaining_time = $would_be_blocked_remaining_time; + $block_reason = $would_be_blocked_reason; + } elseif ( $current_block_remaining_time > 0 ) { + $remaining_time = $current_block_remaining_time; + $block_reason = $current_block_reason; + } else { + // Fallback: re-check block status right before displaying error (block may have expired since initial check). + $block_data = $this->handler->mosp_get_block_data( $user_email, $phone_number, $browser_id, 'otp_send' ); + $remaining_time = $block_data['remaining_time']; + $block_reason = $block_data['reason']; + } + + if ( $would_be_blocked_remaining_time > 0 ) { + $remaining_time = $would_be_blocked_remaining_time; + $block_reason = $would_be_blocked_reason; + } elseif ( $remaining_time <= 0 ) { + $requires_puzzle = $this->handler->mosp_requires_puzzle_verification( $user_email, $phone_number, $this->handler->mosp_get_client_ip(), $browser_id ); + $requires_limit_puzzle = $this->handler->mosp_requires_limit_puzzle_verification( $user_email, $phone_number ); + $requires_puzzle = $requires_puzzle || $requires_limit_puzzle; + + if ( ! $requires_puzzle && ! $attempt_recorded ) { + $this->handler->mosp_record_attempt_for_identifiers( $user_email, $phone_number, $browser_id ); + $attempt_recorded = true; + return; + } + + if ( $requires_puzzle ) { + $is_ajax_form = apply_filters( 'is_ajax_form', false ); + if ( $is_ajax_form || 'ajax_phone' === $user_login ) { + wp_send_json( + array( + 'result' => 'puzzle_required', + 'message' => __( 'Please complete the security verification to continue.', 'miniorange-otp-verification' ), + 'puzzle_required' => true, + 'authType' => 'PUZZLE_REQUIRED', + ) + ); + } else { + $puzzle_email = ! empty( $user_email ) ? $user_email : 'puzzle@temp.local'; + $puzzle_phone = ! empty( $phone_number ) ? $phone_number : null; + miniorange_site_otp_validation_form( $user_login, $puzzle_email, $puzzle_phone, __( 'Please complete the security verification to continue.', 'miniorange-otp-verification' ), $otp_type, $from_both ); + } + exit; + } + + // Block window is over (0s left) but stale $is_blocked — allow OTP send instead of a bogus 00:00 error. + return; + } + + $message = $this->handler->mosp_get_block_message_with_timer( $remaining_time ); + $is_ajax_form = apply_filters( 'is_ajax_form', false ); + + if ( $is_ajax_form || 'ajax_phone' === $user_login ) { + wp_send_json( + array( + 'result' => 'error', + 'message' => $message, + 'blocked' => true, + 'remaining_time' => $remaining_time, + 'blocked_type' => $block_reason, + 'authType' => 'BLOCKED', + 'status' => 'BLOCKED', + ) + ); + exit; + } else { + miniorange_site_otp_validation_form( null, null, null, $message, $otp_type, $from_both ); + exit; + } + } + } + + /** + * Enqueue frontend scripts. + * + * @return void + */ + public function mosp_enqueue_frontend_scripts() { + if ( is_admin() ) { + return; + } + + if ( ! $this->mosp_is_addon_enabled() ) { + return; + } + + if ( ! $this->is_otp_verification_active_on_page() ) { + return; + } + + $settings = $this->storage->mosp_get_settings(); + + wp_enqueue_script( + 'mo-osp-frontend', + MO_OSP_URL . 'includes/js/spam-preventer.js', + array( 'jquery' ), + '1.1.0', + true + ); + + wp_enqueue_script( + 'mo-osp-puzzle-system', + MO_OSP_URL . 'includes/js/puzzle-system.js', + array( 'jquery', 'mo-osp-frontend' ), + '1.0.0', + true + ); + + wp_enqueue_style( + 'mo-osp-puzzle-css', + MO_OSP_URL . 'includes/css/mo-admin.css', + array(), + '1.0.5' + ); + + wp_localize_script( + 'mo-osp-frontend', + 'mo_osp_ajax', + array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'mo_osp_nonce' ), + 'loading_text' => __( 'Checking...', 'miniorange-otp-verification' ), + 'timer_time' => $settings['cooldown_time'], + ) + ); + } + + /** + * Enqueue admin scripts. + * + * @param string $hook_suffix Current admin page. + * @return void + */ + public function mosp_enqueue_admin_scripts( $hook_suffix ) { + if ( strpos( $hook_suffix, 'mo_otp_verification' ) === false ) { + return; + } + + $mo_osp_admin_js = MO_OSP_DIR . 'includes/js/spam-preventer-admin.js'; + wp_enqueue_script( + 'mo-osp-admin', + MO_OSP_URL . 'includes/js/spam-preventer-admin.js', + array( 'jquery' ), + file_exists( $mo_osp_admin_js ) ? (string) filemtime( $mo_osp_admin_js ) : '1.0.1', + true + ); + + wp_localize_script( + 'mo-osp-admin', + 'mo_osp_admin_ajax', + array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'mo_osp_admin_nonce' ), + ) + ); + } + + /** + * Include timer JavaScript for pop-up forms. + * This is called via mo_include_js action when popup is rendered. + */ + public function mosp_include_timer_js() { + if ( ! $this->mosp_is_addon_enabled() ) { + return; + } + + $settings = $this->storage->mosp_get_settings(); + + $email = MoPHPSessions::get_session_var( 'user_email' ); + $phone = MoPHPSessions::get_session_var( 'phone_number_mo' ); + // phpcs:disable WordPress.Security.NonceVerification.Missing + $browser_id = isset( $_POST['mo_osp_browser_id'] ) ? sanitize_text_field( wp_unslash( $_POST['mo_osp_browser_id'] ) ) : ''; + $ip = $this->handler->mosp_get_client_ip(); + $is_ip_whitelisted = false; + if ( ! empty( $ip ) ) { + $is_ip_whitelisted = $this->handler->mosp_is_whitelisted( $ip, 'ip' ); + } + + $cooldown_remaining = $is_ip_whitelisted ? 0 : $this->mosp_get_cooldown_remaining_time( $email, $phone, $browser_id ); + $is_blocked = $this->handler->mosp_is_blocked( $email, $phone, $browser_id, 'popup_render' ); + + /* + * mo_include_js runs mid-document (during popup HTML). Bundled scripts end with IIFEs like })(jQuery); + * Inline code below also invokes jQuery immediately. Force core jQuery to print first so it is defined. + */ + wp_enqueue_script( 'jquery' ); + wp_print_scripts( 'jquery' ); + + wp_register_style( 'mo-osp-puzzle-css-popup', MO_OSP_URL . 'includes/css/mo-admin.css', array(), '1.0.5', 'all' ); + wp_print_styles( 'mo-osp-puzzle-css-popup' ); + + wp_register_script( 'mo-osp-timer', MO_OSP_URL . 'includes/js/spam-preventer.js', array( 'jquery' ), '1.0.0', false ); + wp_localize_script( + 'mo-osp-timer', + 'mo_osp_timer', + array( + 'timer_time' => $settings['cooldown_time'], + ) + ); + wp_print_scripts( 'mo-osp-timer' ); + + wp_register_script( 'mo-osp-puzzle-system', MO_OSP_URL . 'includes/js/puzzle-system.js', array( 'jquery', 'mo-osp-timer' ), '1.0.0', false ); + wp_localize_script( + 'mo-osp-puzzle-system', + 'mo_osp_ajax', + array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'mo_osp_nonce' ), + 'timer_time' => $settings['cooldown_time'], + 'block_time' => $settings['block_time'], + 'enable_logs' => defined( 'WP_DEBUG' ) && WP_DEBUG, + ) + ); + wp_print_scripts( 'mo-osp-puzzle-system' ); + + wp_register_script( 'mo-osp-popup-timer', MO_OSP_URL . 'includes/js/popup-timer.js', array( 'jquery', 'mo-osp-timer', 'mo-osp-puzzle-system' ), '1.0.0', false ); + wp_localize_script( + 'mo-osp-popup-timer', + 'mo_osp_popup_timer', + array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'mo_osp_nonce' ), + 'timer_time' => $settings['cooldown_time'], + 'block_time' => $settings['block_time'], + 'limit_otp_sent' => MoMessages::showMessage( MoMessages::LIMIT_OTP_SENT ), + 'user_blocked' => MoMessages::showMessage( MoMessages::USER_IS_BLOCKED_AJAX ), + 'error_otp_verify' => MoMessages::showMessage( MoMessages::ERROR_OTP_VERIFY ), + 'initial_cooldown_time' => $cooldown_remaining, + 'initial_blocked' => $is_blocked, + 'puzzle_required_text' => __( 'Please complete the security verification to continue.', 'miniorange-otp-verification' ), + ) + ); + wp_print_scripts( 'mo-osp-popup-timer' ); + + echo '<div id="mo-osp-puzzle-popup-outer-div" style="display:none;">'; + MoPuzzleHelper::mosp_render_puzzle_popup(); + echo '</div>'; + $puzzle_message = __( 'Please complete the security verification to continue.', 'miniorange-otp-verification' ); + ?> + <script type="text/javascript"> + (function() { + function moOspRunInlinePuzzle($) { + 'use strict'; + + if (window.mo_osp_inline_puzzle_check_executed) { + return; + } + window.mo_osp_inline_puzzle_check_executed = true; + + function checkAndShowPuzzle() { + if (window.mo_osp_puzzle_shown) { + return; + } + + var $popupBody = $('.mo_customer_validation-modal-body'); + var $firstDiv = $popupBody.children('div').first(); + var messageText = ''; + + if ($firstDiv.length > 0) { + messageText = $firstDiv.text().trim(); + } + if (!messageText && $popupBody.length > 0) { + messageText = $popupBody.text().trim(); + } + + var puzzleText = '<?php echo esc_js( $puzzle_message ); ?>'; + + if (messageText && messageText.toLowerCase().includes(puzzleText.toLowerCase())) { + + window.mo_osp_puzzle_shown = true; + + $('#mo_site_otp_form').hide(); + $('.mo_customer_validation-modal').hide(); + $('.mo-modal-backdrop').hide(); + + if ($('#mo-osp-puzzle-overlay').length === 0) { + return; + } + + var $puzzleOverlay = $('#mo-osp-puzzle-overlay'); + $puzzleOverlay.css('z-index', '100001'); + + $('#mo-osp-puzzle-popup-outer-div').show().css('z-index', '100002'); + $puzzleOverlay.removeClass('mo-osp-hidden'); + + window.MO_OSP_Puzzle_onPopupSuccess = function() { + if (typeof window.MO_OSP_Puzzle !== 'undefined' && window.MO_OSP_Puzzle.closePuzzle) { + window.MO_OSP_Puzzle.closePuzzle(); + } else { + jQuery('#mo-osp-puzzle-overlay').addClass('mo-osp-hidden'); + jQuery('#mo-osp-puzzle-popup-outer-div').hide(); + } + + var resendForm = document.getElementById('verification_resend_otp_form'); + if (resendForm) { + if (!resendForm.querySelector('input[name="puzzle_verified"]')) { + var puzzleVerifiedInput = document.createElement('input'); + puzzleVerifiedInput.type = 'hidden'; + puzzleVerifiedInput.name = 'puzzle_verified'; + puzzleVerifiedInput.value = 'true'; + resendForm.appendChild(puzzleVerifiedInput); + } + resendForm.submit(); + } else { + sessionStorage.setItem('mo_osp_puzzle_completed', 'true'); + window.location.reload(); + } + }; + + if (typeof window.MO_OSP_Puzzle !== 'undefined') { + if (typeof window.MO_OSP_Puzzle.init === 'function' && !window.MO_OSP_Puzzle.initialized) { + window.MO_OSP_Puzzle.init(); + window.MO_OSP_Puzzle.initialized = true; + } + window.MO_OSP_Puzzle.showPuzzle({}); + } else { + setTimeout(function() { + if (typeof window.MO_OSP_Puzzle !== 'undefined') { + if (typeof window.MO_OSP_Puzzle.init === 'function' && !window.MO_OSP_Puzzle.initialized) { + window.MO_OSP_Puzzle.init(); + window.MO_OSP_Puzzle.initialized = true; + } + window.MO_OSP_Puzzle.showPuzzle({}); + } else { + console.error('MO_OSP_Puzzle still not available after wait'); + } + }, 500); + } + } + } + + var checkExecuted = false; + function runCheckOnce() { + if (checkExecuted) { + return; + } + checkExecuted = true; + checkAndShowPuzzle(); + } + + if (document.readyState === 'loading') { + $(document).ready(function() { + setTimeout(runCheckOnce, 300); + }); + } else { + setTimeout(runCheckOnce, 300); + } + } + function moOspTryInlinePuzzle() { + var jq = window.jQuery; + if (typeof jq === 'undefined') { + return false; + } + moOspRunInlinePuzzle(jq); + return true; + } + if (!moOspTryInlinePuzzle()) { + var moOspInlineIv = setInterval(function () { + if (moOspTryInlinePuzzle()) { + clearInterval(moOspInlineIv); + } + }, 30); + setTimeout(function () { + clearInterval(moOspInlineIv); + }, 15000); + } + })(); + </script> + <?php + } + + /** + * Add puzzle popup HTML to frontend. + */ + public function mosp_add_puzzle_popup_to_frontend() { + if ( is_admin() ) { + return; + } + + if ( ! $this->mosp_is_addon_enabled() ) { + return; + } + + if ( ! $this->is_otp_verification_active_on_page() ) { + return; + } + + echo '<div id="mo-osp-puzzle-popup-outer-div" style="display:none;">'; + MoPuzzleHelper::mosp_render_puzzle_popup(); + echo '</div>'; + } + + /** + * Check if OTP verification is active on the current page. + * + * @return bool True if any form has OTP verification enabled + */ + private function is_otp_verification_active_on_page() { + $form_list = FormList::instance(); + $all_forms = $form_list->get_list(); + + foreach ( $all_forms as $form_handler ) { + if ( $form_handler && method_exists( $form_handler, 'is_form_enabled' ) ) { + if ( $form_handler->is_form_enabled() ) { + return true; + } + } + } + + $otp_verification_options = array( + 'cf_submit_id', + 'wc_default_enable', + 'wp_default_enable', + 'wp_login_enable', + 'wc_checkout_enable', + 'bp_registration_enable', + 'um_default_enable', + 'pmpro_default_enable', + ); + + foreach ( $otp_verification_options as $option ) { + if ( get_mo_option( $option ) ) { + return true; + } + } + + return false; + } + + /** + * Provide addon cooldown time to host plugin for server-side formatting. + * + * @param int $default_value Default fallback value. + * @return int seconds + */ + public function mosp_filter_get_cooldown_time( $default_value = 60 ) { + if ( ! $this->mosp_is_addon_enabled() ) { + return (int) $default_value; + } + + $settings = $this->storage->mosp_get_settings(); + return isset( $settings['cooldown_time'] ) ? (int) $settings['cooldown_time'] : (int) $default_value; + } + + /** + * Check if spam preventer addon is enabled in settings. + * + * @return bool + */ + private function mosp_is_addon_enabled() { + $settings = $this->storage->mosp_get_settings(); + return ! empty( $settings['enabled'] ); + } + + /** + * Get remaining cooldown time for user. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param string $browser_id Browser ID. + * @return int Remaining cooldown time in seconds. + */ + private function mosp_get_cooldown_remaining_time( $email, $phone, $browser_id ) { + $identifiers = $this->handler->mosp_get_all_identifiers( $email, $phone, $this->handler->mosp_get_client_ip(), $browser_id ); + $current_time = time(); + $settings = $this->storage->mosp_get_settings(); + $cooldown_time = $settings['cooldown_time']; + + foreach ( $identifiers as $identifier ) { + $key = $this->storage->mosp_hash_key( $identifier ); + $data = $this->storage->mosp_get_spam_data( $key ); + + if ( false !== $data && isset( $data['last_attempt'] ) && $data['last_attempt'] > 0 ) { + $time_since_last = $current_time - $data['last_attempt']; + $remaining = $cooldown_time - $time_since_last; + + if ( $remaining > 0 ) { + return $remaining; + } + } + } + + return 0; + } + } +} @@ -1,181 +1,181 @@ -<?php -/** - * OTP Spam Preventer Handler - * - * @package otpspampreventer/handler - */ - -namespace OSP\Handler; - -use OSP\Handler\MoOtpSpamPreventerHandler; -use OSP\Handler\MoOtpSpamStorage; -use OSP\Handler\MoOtpSpamAjax; -use OSP\Traits\Instance; -use OTP\Objects\BaseAddOnHandler; -use OTP\Helper\AddOnList; -use OTP\Helper\MoUtility; -use OTP\Helper\MoMessages; - -if ( ! defined( 'ABSPATH' ) ) { - exit; -} - -if ( ! class_exists( 'MoOtpSpamPreventerAddonHandler' ) ) { - /** - * The class is used to handle all OTP Spam Preventer related functionality. - */ - class MoOtpSpamPreventerAddonHandler extends BaseAddOnHandler { - - use Instance; - - /** - * Constructor checks if add-on has been enabled by the admin and initializes - * all the class variables. This function also defines all the hooks to - * hook into to make the add-on functionality work. - */ - public function __construct() { - parent::__construct(); - add_action( 'admin_enqueue_scripts', array( $this, 'mo_enqueue_admin_assets' ) ); - if ( ! $this->moAddOnV() ) { - return; - } - MoOtpSpamPreventerHandler::instance(); - MoOtpSpamStorage::instance(); - MoOtpSpamAjax::instance(); - - add_action( 'admin_init', array( $this, 'mo_handle_settings_save' ) ); - } - - /** - * Set a unique key for the AddOn - */ - public function set_addon_key() { - $this->add_on_key = 'otp_spam_preventer'; - } - - /** - * Set a AddOn Description - * Store raw string to avoid early translation loading warning. - */ - public function set_add_on_desc() { - $this->add_on_desc = 'Prevents OTP request spamming based on phone number, email, IP address, and browser fingerprint. ' - . 'Click on the settings button to the right to configure settings for the same.'; - } - - /** - * Set an AddOnName - * Store raw string to avoid early translation loading warning. - */ - public function set_add_on_name() { - $this->addon_name = 'OTP Spam Preventer'; - } - - /** - * Return the Addon Description (with lazy translation) - * - * @return string - */ - public function getAddOnDesc() { - if ( did_action( 'plugins_loaded' ) ) { - // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- Dynamic translation needed for lazy loading. - return __( $this->add_on_desc, 'miniorange-otp-verification' ); - } - return $this->add_on_desc; - } - - /** - * Return AddOn Name (with lazy translation) - * - * @return string - */ - public function get_add_on_name() { - if ( did_action( 'plugins_loaded' ) ) { - // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- Dynamic translation needed for lazy loading. - return __( $this->addon_name, 'miniorange-otp-verification' ); - } - return $this->addon_name; - } - - /** - * Set Settings Page URL - */ - public function set_settings_url() { - $req_url = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- esc_url_raw() handles sanitization. - $this->settings_url = add_query_arg( array( 'addon' => 'otp_spam_preventer' ), $req_url ); - } - - /** - * Set an Addon Docs link - */ - public function set_add_on_docs() {} - - /** - * Set an Addon Video link - */ - public function set_add_on_video() {} - - /** - * Handle settings save POST request. - * - * @return void - */ - public function mo_handle_settings_save() { - if ( ! isset( $_POST['option'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- false positive. - return; - } - - $option = sanitize_text_field( wp_unslash( $_POST['option'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- false positive. - if ( 'mo_osp_settings_save' !== $option ) { - return; - } - - check_admin_referer( 'mo_osp_settings_save' ); - - if ( ! current_user_can( 'manage_options' ) ) { - wp_die( esc_html( MoMessages::showMessage( MoMessages::INSUFFICIENT_PERMISSIONS ) ) ); - } - - $handler = MoOtpSpamPreventerHandler::instance(); - $posted = MoUtility::mo_sanitize_array( $_POST ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitized within the function. - $result = $handler->mosp_save_settings( $posted ); - - $message_type = $result['success'] ? 'SUCCESS' : 'ERROR'; - do_action( 'mo_registration_show_message', $result['message'], $message_type ); - } - - /** - * Enqueue admin assets. - * - * @param string $hook_suffix Current admin page hook suffix. Not used but required by hook signature. - * @return void - */ - public function mo_enqueue_admin_assets( $hook_suffix ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- Required by admin_enqueue_scripts hook signature. - if ( ! isset( $_GET['addon'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended -- Reading GET parameter for checking the addon name, doesn't require nonce verification. - return; - } - - $addon = sanitize_text_field( wp_unslash( $_GET['addon'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended -- Reading GET parameter for checking the addon name, doesn't require nonce verification. - if ( 'otp_spam_preventer' !== $addon ) { - return; - } - - wp_enqueue_style( 'mo-osp-admin', MO_OSP_URL . 'includes/css/mo-admin.css', array(), '1.6.0' ); - $mo_osp_admin_js = MO_OSP_DIR . 'includes/js/spam-preventer-admin.js'; - wp_enqueue_script( - 'mo-osp-admin', - MO_OSP_URL . 'includes/js/spam-preventer-admin.js', - array( 'jquery' ), - file_exists( $mo_osp_admin_js ) ? (string) filemtime( $mo_osp_admin_js ) : '1.0.1', - true - ); - wp_localize_script( - 'mo-osp-admin', - 'mo_osp_admin_ajax', - array( - 'ajax_url' => admin_url( 'admin-ajax.php' ), - 'nonce' => wp_create_nonce( 'mo_osp_admin_nonce' ), - ) - ); - } - } -} +<?php +/** + * OTP Spam Preventer Handler + * + * @package otpspampreventer/handler + */ + +namespace OSP\Handler; + +use OSP\Handler\MoOtpSpamPreventerHandler; +use OSP\Handler\MoOtpSpamStorage; +use OSP\Handler\MoOtpSpamAjax; +use OSP\Traits\Instance; +use OTP\Objects\BaseAddOnHandler; +use OTP\Helper\AddOnList; +use OTP\Helper\MoUtility; +use OTP\Helper\MoMessages; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +if ( ! class_exists( 'MoOtpSpamPreventerAddonHandler' ) ) { + /** + * The class is used to handle all OTP Spam Preventer related functionality. + */ + class MoOtpSpamPreventerAddonHandler extends BaseAddOnHandler { + + use Instance; + + /** + * Constructor checks if add-on has been enabled by the admin and initializes + * all the class variables. This function also defines all the hooks to + * hook into to make the add-on functionality work. + */ + public function __construct() { + parent::__construct(); + add_action( 'admin_enqueue_scripts', array( $this, 'mo_enqueue_admin_assets' ) ); + if ( ! $this->moAddOnV() ) { + return; + } + MoOtpSpamPreventerHandler::instance(); + MoOtpSpamStorage::instance(); + MoOtpSpamAjax::instance(); + + add_action( 'admin_init', array( $this, 'mo_handle_settings_save' ) ); + } + + /** + * Set a unique key for the AddOn + */ + public function set_addon_key() { + $this->add_on_key = 'otp_spam_preventer'; + } + + /** + * Set a AddOn Description + * Store raw string to avoid early translation loading warning. + */ + public function set_add_on_desc() { + $this->add_on_desc = 'Prevents OTP request spamming based on phone number, email, IP address, and browser fingerprint. ' + . 'Click on the settings button to the right to configure settings for the same.'; + } + + /** + * Set an AddOnName + * Store raw string to avoid early translation loading warning. + */ + public function set_add_on_name() { + $this->addon_name = 'OTP Spam Preventer'; + } + + /** + * Return the Addon Description (with lazy translation) + * + * @return string + */ + public function getAddOnDesc() { + if ( did_action( 'plugins_loaded' ) ) { + // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- Dynamic translation needed for lazy loading. + return __( $this->add_on_desc, 'miniorange-otp-verification' ); + } + return $this->add_on_desc; + } + + /** + * Return AddOn Name (with lazy translation) + * + * @return string + */ + public function get_add_on_name() { + if ( did_action( 'plugins_loaded' ) ) { + // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- Dynamic translation needed for lazy loading. + return __( $this->addon_name, 'miniorange-otp-verification' ); + } + return $this->addon_name; + } + + /** + * Set Settings Page URL + */ + public function set_settings_url() { + $req_url = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- esc_url_raw() handles sanitization. + $this->settings_url = add_query_arg( array( 'addon' => 'otp_spam_preventer' ), $req_url ); + } + + /** + * Set an Addon Docs link + */ + public function set_add_on_docs() {} + + /** + * Set an Addon Video link + */ + public function set_add_on_video() {} + + /** + * Handle settings save POST request. + * + * @return void + */ + public function mo_handle_settings_save() { + if ( ! isset( $_POST['option'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- false positive. + return; + } + + $option = sanitize_text_field( wp_unslash( $_POST['option'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- false positive. + if ( 'mo_osp_settings_save' !== $option ) { + return; + } + + check_admin_referer( 'mo_osp_settings_save' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html( MoMessages::showMessage( MoMessages::INSUFFICIENT_PERMISSIONS ) ) ); + } + + $handler = MoOtpSpamPreventerHandler::instance(); + $posted = MoUtility::mo_sanitize_array( $_POST ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitized within the function. + $result = $handler->mosp_save_settings( $posted ); + + $message_type = $result['success'] ? 'SUCCESS' : 'ERROR'; + do_action( 'mo_registration_show_message', $result['message'], $message_type ); + } + + /** + * Enqueue admin assets. + * + * @param string $hook_suffix Current admin page hook suffix. Not used but required by hook signature. + * @return void + */ + public function mo_enqueue_admin_assets( $hook_suffix ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- Required by admin_enqueue_scripts hook signature. + if ( ! isset( $_GET['addon'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended -- Reading GET parameter for checking the addon name, doesn't require nonce verification. + return; + } + + $addon = sanitize_text_field( wp_unslash( $_GET['addon'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended -- Reading GET parameter for checking the addon name, doesn't require nonce verification. + if ( 'otp_spam_preventer' !== $addon ) { + return; + } + + wp_enqueue_style( 'mo-osp-admin', MO_OSP_URL . 'includes/css/mo-admin.css', array(), '1.6.0' ); + $mo_osp_admin_js = MO_OSP_DIR . 'includes/js/spam-preventer-admin.js'; + wp_enqueue_script( + 'mo-osp-admin', + MO_OSP_URL . 'includes/js/spam-preventer-admin.js', + array( 'jquery' ), + file_exists( $mo_osp_admin_js ) ? (string) filemtime( $mo_osp_admin_js ) : '1.0.1', + true + ); + wp_localize_script( + 'mo-osp-admin', + 'mo_osp_admin_ajax', + array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'mo_osp_admin_nonce' ), + ) + ); + } + } +} @@ -1,2231 +1,2231 @@ -<?php -/** - * OTP Spam Preventer Main Handler - * - * @package otpspampreventer/handler - */ - -namespace OSP\Handler; - -use OSP\Handler\MoOtpSpamStorage; -use OSP\Helper\MoRateLimitHelper; -use OSP\Helper\MoSecurityHelper; -use OSP\Traits\Instance; -use OTP\Helper\MoMessages; -use OTP\Helper\MoPHPSessions; -use OTP\Helper\MoUtility; - - -if ( ! defined( 'ABSPATH' ) ) { - exit; -} - -if ( ! class_exists( 'MoOtpSpamPreventerHandler' ) ) { - /** - * The main handler class for OTP Spam Prevention functionality. - * Integrates with the existing OTP verification flow. - * Updated: Fixed block expiration logic - */ - class MoOtpSpamPreventerHandler { - - use Instance; - - /** - * Storage instance - * - * @var MoOtpSpamStorage - */ - private $storage; - - /** - * Constructor - */ - public function __construct() { - $this->storage = MoOtpSpamStorage::instance(); - MoRateLimitHelper::init( $this->storage ); - } - - /** - * Save settings from POST data. - * - * @param array $posted POST data array. - * @return array Result array with 'success' and 'message' keys. - */ - public function mosp_save_settings( $posted ) { - $settings = array(); - $settings['enabled'] = isset( $posted['mo_osp_enabled'] ); - $settings['cooldown_time'] = isset( $posted['mo_osp_cooldown_time'] ) ? absint( $posted['mo_osp_cooldown_time'] ) : 60; - - $max_attempts = isset( $posted['mo_osp_max_attempts'] ) ? absint( $posted['mo_osp_max_attempts'] ) : 3; - $settings['max_attempts'] = max( 1, min( 10, $max_attempts ) ); - - $settings['block_time'] = isset( $posted['mo_osp_block_time'] ) ? absint( $posted['mo_osp_block_time'] ) : 900; - - $settings['daily_limit'] = isset( $posted['mo_osp_daily_limit'] ) ? absint( $posted['mo_osp_daily_limit'] ) : 10; - $settings['hourly_limit'] = isset( $posted['mo_osp_hourly_limit'] ) ? absint( $posted['mo_osp_hourly_limit'] ) : 5; - - $validation_errors = array(); - - if ( $settings['hourly_limit'] <= $settings['max_attempts'] ) { - $validation_errors[] = 'Hourly limit (' . $settings['hourly_limit'] . ') must be greater than max attempts per window (' . $settings['max_attempts'] . ')'; - } - - if ( $settings['daily_limit'] <= $settings['hourly_limit'] ) { - $validation_errors[] = 'Daily limit (' . $settings['daily_limit'] . ') must be greater than hourly limit (' . $settings['hourly_limit'] . ')'; - } - - if ( ! empty( $validation_errors ) ) { - $error_message = 'Settings validation failed: ' . implode( '; ', $validation_errors ); - do_action( 'mo_otp_verification_show_message', $error_message, 'ERROR' ); - return array( - 'success' => false, - 'message' => $error_message, - ); - } - - $settings['track_phone'] = true; - $settings['track_email'] = true; - $settings['track_ip'] = true; - $settings['track_browser'] = true; - - // Process whitelists. - $whitelist_ips = isset( $posted['mo_osp_whitelist_ips'] ) ? sanitize_textarea_field( wp_unslash( $posted['mo_osp_whitelist_ips'] ) ) : ''; - $settings['whitelist_ips'] = array_filter( array_map( 'trim', explode( "\n", $whitelist_ips ) ) ); - $settings['whitelist_ips'] = array_values( $settings['whitelist_ips'] ); - - $result = $this->storage->mosp_update_settings( $settings ); - - if ( $result ) { - return array( - 'success' => true, - 'message' => __( 'Settings saved successfully!', 'miniorange-otp-verification' ), - ); - } else { - return array( - 'success' => false, - 'message' => __( 'Failed to save settings!', 'miniorange-otp-verification' ), - ); - } - } - - /** - * Check if identifier is whitelisted. - * - * @param string $identifier The identifier to check (IP, email, phone, etc.). - * @param string $type The type of identifier (ip, email, phone). - * @return bool True if whitelisted, false otherwise. - */ - public function mosp_is_whitelisted( $identifier, $type ) { - return $this->storage->mosp_is_whitelisted( $identifier, $type ); - } - - /** - * Check if a request should be blocked due to spam prevention rules - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param string $browser_id Browser fingerprint ID. - * @param string $context Context of the check ('otp_send' or 'timer_status'). - * @return bool True if blocked, false if allowed - */ - public function mosp_is_blocked( $email, $phone, $browser_id = '', $context = 'otp_send' ) { - if ( ! $this->mosp_is_addon_enabled() ) { - return false; - } - - $settings = $this->storage->mosp_get_settings(); - - $ip = $this->mosp_get_client_ip(); - - if ( ! empty( $ip ) ) { - $is_whitelisted = $this->storage->mosp_is_whitelisted( $ip, 'ip' ); - if ( $is_whitelisted ) { - return false; - } - } - - $this->mosp_log_security_event( $email, $phone, $ip, $browser_id ); - - $ip_switching_detected = $this->detect_ip_switching_attack( $email, $phone, $browser_id, $ip ); - if ( $ip_switching_detected ) { - $this->mosp_log_security_event( $email, $phone, $ip, $browser_id, 'IP_SWITCHING_DETECTED' ); - return true; - } - - $daily_limit_exceeded = $this->mosp_is_daily_limit_exceeded( $email, $phone, $settings, $context ); - if ( $daily_limit_exceeded ) { - return true; - } - - $hourly_limit_exceeded = $this->mosp_is_hourly_limit_exceeded( $email, $phone, $settings, $context ); - if ( $hourly_limit_exceeded ) { - return true; - } - - $identifiers = $this->mosp_get_all_identifiers( $email, $phone, $ip, $browser_id ); - - foreach ( $identifiers as $identifier ) { - $identifier_blocked = $this->is_identifier_blocked( $identifier ); - if ( $identifier_blocked ) { - return true; - } - } - - $cross_identifier_blocked = $this->mosp_is_cross_identifier_blocked( $email, $phone, $ip, $browser_id ); - if ( $cross_identifier_blocked ) { - return true; - } - - return false; - } - - /** - * Record an OTP attempt for rate limiting (new method for integration) - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param string $browser_id Browser fingerprint ID. - * @return void - */ - public function mosp_record_attempt_for_identifiers( $email, $phone, $browser_id = '' ) { - if ( ! $this->mosp_is_addon_enabled() ) { - return; - } - - $settings = $this->storage->mosp_get_settings(); - - $ip = $this->mosp_get_client_ip(); - - $identifiers = $this->mosp_get_all_identifiers( $email, $phone, $ip, $browser_id ); - - $current_time = time(); - $context = array( - 'ip' => $ip, - 'browser_id' => $browser_id, - 'email' => $email, - 'phone' => $phone, - ); - - foreach ( $identifiers as $identifier ) { - $this->mosp_record_identifier_attempt( $identifier, $current_time, $context ); - } - - $this->mosp_record_cross_identifier_attempt( $email, $phone, $ip, $browser_id, $current_time, $context ); - - $this->mosp_record_daily_hourly_attempts( $email, $phone ); - } - - /** - * Store a block for all identifiers when a block is detected via "would be blocked" check. - * This ensures the block persists in the database so the timer doesn't reset on each click. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param string $browser_id Browser fingerprint ID. - * @param string $block_reason Reason for the block (e.g., 'max_attempts_exceeded', 'cooldown'). - * @param int $remaining_time Remaining time in seconds until block expires. - * @return void - */ - public function mosp_store_block_for_identifiers( $email, $phone, $browser_id, $block_reason, $remaining_time ) { - $ip = $this->mosp_get_client_ip(); - $identifiers = $this->mosp_get_all_identifiers( $email, $phone, $ip, $browser_id ); - $now = time(); - $blocked_until = $now + $remaining_time; - - // Extract identifier type and value for storage. - $identifier_type_map = array(); - foreach ( $identifiers as $identifier ) { - if ( strpos( $identifier, 'email:' ) === 0 ) { - $identifier_type_map[ $identifier ] = array( - 'type' => 'email', - 'value' => substr( $identifier, 6 ), - ); - } elseif ( strpos( $identifier, 'phone:' ) === 0 ) { - $identifier_type_map[ $identifier ] = array( - 'type' => 'phone', - 'value' => substr( $identifier, 6 ), - ); - } elseif ( strpos( $identifier, 'ip:' ) === 0 ) { - $identifier_type_map[ $identifier ] = array( - 'type' => 'ip', - 'value' => substr( $identifier, 3 ), - ); - } elseif ( strpos( $identifier, 'browser:' ) === 0 ) { - $identifier_type_map[ $identifier ] = array( - 'type' => 'browser', - 'value' => substr( $identifier, 8 ), - ); - } else { - $identifier_type_map[ $identifier ] = array( - 'type' => 'unknown', - 'value' => $identifier, - ); - } - } - - foreach ( $identifiers as $identifier ) { - $key = $this->storage->mosp_hash_key( $identifier ); - $data = $this->storage->mosp_get_spam_data( $key ); - - $id_info = isset( $identifier_type_map[ $identifier ] ) ? $identifier_type_map[ $identifier ] : array( - 'type' => 'unknown', - 'value' => '', - ); - - if ( false === $data ) { - $data = array( - 'type' => $id_info['type'], - 'identifier' => $id_info['value'], // Store original identifier value. - 'attempts' => array(), - 'blocked_until' => 0, - 'total_blocks' => 0, - 'created' => $now, - 'last_attempt' => $now, - ); - } else { - // Update type if not set or is 'identifier' or 'unknown'. - if ( ! isset( $data['type'] ) || 'identifier' === $data['type'] || 'unknown' === $data['type'] ) { - $data['type'] = $id_info['type']; - } - // Store original identifier value if not set. - if ( ! isset( $data['identifier'] ) || empty( $data['identifier'] ) ) { - $data['identifier'] = $id_info['value']; - } - } - - if ( ! isset( $data['blocked_until'] ) || $data['blocked_until'] < $blocked_until ) { - $old_blocked_until = isset( $data['blocked_until'] ) ? $data['blocked_until'] : 0; - $data['blocked_until'] = $blocked_until; - $data['block_reason'] = $block_reason; - - // Store original identifier value for display (admin-only access, no masking needed). - if ( ! empty( $id_info['value'] ) ) { - $data['identifier'] = $id_info['value']; - } - - $this->storage->mosp_update_spam_data( $key, $data ); - } - } - } - - /** - * Get all identifiers for the current request. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param string $ip IP address. - * @param string $browser_id Browser fingerprint ID. - * @return array Array of identifiers - */ - public function mosp_get_all_identifiers( $email, $phone, $ip, $browser_id ) { - $identifiers = array(); - - $norm_email = $this->mosp_normalize_email_for_spam( $email ); - if ( '' !== $norm_email ) { - $identifiers[] = 'email:' . $norm_email; - } - - $norm_phone = $this->mosp_normalize_phone_for_spam( $phone ); - if ( '' !== $norm_phone ) { - $identifiers[] = 'phone:' . $norm_phone; - } - - if ( ! empty( $ip ) ) { - $identifiers[] = 'ip:' . $ip; - } - - if ( ! empty( $browser_id ) ) { - $identifiers[] = 'browser:' . $browser_id; - } - - return $identifiers; - } - - /** - * Normalize email for spam/rate-limit keys (stable casing). - * - * @param string $email Email. - * @return string - */ - private function mosp_normalize_email_for_spam( $email ) { - return strtolower( trim( (string) $email ) ); - } - - /** - * Normalize phone the same way as puzzle verification (MoUtility) so reset/clear hits the same DB rows as OTP send. - * - * @param string $phone Phone. - * @return string - */ - private function mosp_normalize_phone_for_spam( $phone ) { - $phone = trim( (string) $phone ); - if ( '' === $phone ) { - return ''; - } - if ( class_exists( '\OTP\Helper\MoUtility' ) ) { - $processed = MoUtility::process_phone_number( $phone ); - $digits = preg_replace( '/\D/', '', (string) $processed ); - if ( strlen( $digits ) >= 6 ) { - return $processed; - } - } - return preg_replace( '/[^0-9+]/', '', $phone ); - } - - /** - * Cross-identifier strings (IP + credential) using normalized email/phone. - * - * @param string $email Email. - * @param string $phone Phone. - * @param string $ip IP. - * @param string $browser_id Browser id. - * @return string[] - */ - private function mosp_build_cross_identifier_strings( $email, $phone, $ip, $browser_id ) { - if ( empty( $ip ) ) { - return array(); - } - $cross = array(); - $ne = $this->mosp_normalize_email_for_spam( $email ); - if ( '' !== $ne ) { - $cross[] = 'cross_ip_email:' . $ip . '|' . $ne; - } - $np = $this->mosp_normalize_phone_for_spam( $phone ); - if ( '' !== $np ) { - $cross[] = 'cross_ip_phone:' . $ip . '|' . $np; - } - if ( ! empty( $browser_id ) ) { - $cross[] = 'cross_ip_browser:' . $ip . '|' . $browser_id; - } - return $cross; - } - - /** - * Every spam row to clear after puzzle success (canonical + legacy raw-phone keys). - * - * @param string $email Email. - * @param string $phone Phone. - * @param string $ip IP. - * @param string $browser_id Browser id. - * @return string[] - */ - private function mosp_get_identifiers_to_reset_on_puzzle_success( $email, $phone, $ip, $browser_id ) { - $out = array_merge( - $this->mosp_get_all_identifiers( $email, $phone, $ip, $browser_id ), - $this->mosp_build_cross_identifier_strings( $email, $phone, $ip, $browser_id ) - ); - - $norm_phone = $this->mosp_normalize_phone_for_spam( $phone ); - $raw_phone = trim( (string) $phone ); - if ( '' !== $raw_phone ) { - $legacy_vals = array_unique( - array_filter( - array( - $raw_phone, - preg_replace( '/[^0-9+]/', '', $raw_phone ), - ) - ) - ); - foreach ( $legacy_vals as $lp ) { - if ( '' === $lp || $lp === $norm_phone ) { - continue; - } - $out[] = 'phone:' . $lp; - if ( ! empty( $ip ) ) { - $out[] = 'cross_ip_phone:' . $ip . '|' . $lp; - } - } - } - - return array_values( array_unique( array_filter( $out ) ) ); - } - - /** - * Check cross-identifier blocking (IP + OTP type combination). - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param string $ip IP address. - * @param string $browser_id Browser fingerprint ID. - * @return bool True if should be blocked - */ - private function mosp_is_cross_identifier_blocked( $email, $phone, $ip, $browser_id ) { - foreach ( $this->mosp_build_cross_identifier_strings( $email, $phone, $ip, $browser_id ) as $cross_identifier ) { - if ( $this->is_identifier_blocked( $cross_identifier ) ) { - return true; - } - } - return false; - } - - /** - * Record cross-identifier attempt. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param string $ip IP address. - * @param string $browser_id Browser fingerprint ID. - * @param int $current_time Current timestamp. - * @param array $context Context array. - * @return void - */ - private function mosp_record_cross_identifier_attempt( $email, $phone, $ip, $browser_id, $current_time, $context = array() ) { - foreach ( $this->mosp_build_cross_identifier_strings( $email, $phone, $ip, $browser_id ) as $cross_identifier ) { - $this->mosp_record_identifier_attempt( $cross_identifier, $current_time, $context ); - } - } - - /** - * Get block data for identifiers. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param string $browser_id Browser fingerprint ID. - * @param string $context Context of the check ('otp_send' or 'timer_status'). - * @return array Block data with remaining time - */ - public function mosp_get_block_data( $email, $phone, $browser_id = '', $context = 'otp_send' ) { - if ( ! $this->mosp_is_addon_enabled() ) { - return array( - 'remaining_time' => 0, - 'reason' => '', - ); - } - - $current_time = time(); - $settings = $this->storage->mosp_get_settings(); - - $ip = $this->mosp_get_client_ip(); - - if ( ! empty( $ip ) ) { - $is_whitelisted = $this->storage->mosp_is_whitelisted( $ip, 'ip' ); - if ( $is_whitelisted ) { - return array( - 'remaining_time' => 0, - 'reason' => '', - ); - } - } - - $max_remaining_time = 0; - $block_reason = ''; - - if ( $this->mosp_is_daily_limit_exceeded( $email, $phone, $settings, $context ) ) { - $max_remaining_time = $this->mosp_get_daily_limit_reset_time( $email, $phone ); - $block_reason = 'daily_limit_exceeded'; - } elseif ( $this->mosp_is_hourly_limit_exceeded( $email, $phone, $settings, $context ) ) { - $max_remaining_time = $this->mosp_get_hourly_limit_reset_time( $email, $phone ); - $block_reason = 'hourly_limit_exceeded'; - } - - if ( $max_remaining_time <= 0 ) { - $identifiers = $this->mosp_get_all_identifiers( $email, $phone, $ip, $browser_id ); - - foreach ( $identifiers as $identifier ) { - $block_data = $this->storage->mosp_is_blocked( $identifier ); - - if ( $block_data['blocked'] ) { - $blocked_until = $block_data['blocked_until']; - $remaining = $blocked_until - $current_time; - if ( $remaining > $max_remaining_time ) { - $max_remaining_time = $remaining; - $block_reason = $block_data['reason']; - } - } - } - } - - $final_remaining = max( 0, $max_remaining_time ); - return array( - 'remaining_time' => $final_remaining, - 'reason' => $block_reason, - ); - } - - /** - * Get block message with timer. - * - * @param int $remaining_time Remaining block time in seconds. - * @param string $message_type Optional message type constant (defaults to USER_IS_BLOCKED_AJAX). - * @return string Block message with timer placeholder - */ - public function mosp_get_block_message_with_timer( $remaining_time, $message_type = null ) { - $minutes = floor( $remaining_time / 60 ); - $seconds = $remaining_time % 60; - - $formatted_minutes = sprintf( '%02d', $minutes ); - $formatted_seconds = sprintf( '%02d', $seconds ); - - $message_constant = $message_type ? $message_type : MoMessages::USER_IS_BLOCKED_AJAX; - $message_template = MoMessages::showMessage( $message_constant ); - - $message = $message_template; - - if ( strpos( $message, '{minutes}' ) !== false || strpos( $message, '{seconds}' ) !== false ) { - $message = str_replace( - array( '{minutes}', '{seconds}' ), - array( $formatted_minutes, $formatted_seconds ), - $message - ); - } - - if ( strpos( $message, '{{remaining_time}}' ) !== false ) { - $time_display = sprintf( '%02d:%02d', $minutes, $seconds ); - $message = str_replace( '{{remaining_time}}', $time_display, $message ); - } - - if ( strpos( $message, '%' ) !== false ) { - $time_display = sprintf( '%02d:%02d', $minutes, $seconds ); - $message = sprintf( $message, $time_display ); - } - - return $message; - } - - /** - * Check if user would be blocked after recording one more attempt. - * This prevents race condition where OTP is sent successfully but user gets blocked immediately after - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param string $browser_id Browser fingerprint ID. - * @return bool True if would be blocked after attempt, false otherwise - */ - public function mosp_would_be_blocked_after_attempt( $email, $phone, $browser_id = '' ) { - $result = $this->mosp_would_be_blocked_after_attempt_with_details( $email, $phone, $browser_id ); - return $result['would_be_blocked']; - } - - /** - * Check if user would be blocked after recording one more attempt, with detailed information. - * This prevents race condition where OTP is sent successfully but user gets blocked immediately after. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param string $browser_id Browser fingerprint ID. - * @return array Array with 'would_be_blocked' (bool), 'reason' (string), and 'remaining_time' (int) - */ - public function mosp_would_be_blocked_after_attempt_with_details( $email, $phone, $browser_id = '' ) { - if ( ! $this->mosp_is_addon_enabled() ) { - return array( - 'would_be_blocked' => false, - 'reason' => '', - 'remaining_time' => 0, - ); - } - - $settings = $this->storage->mosp_get_settings(); - $ip = $this->mosp_get_client_ip(); - $identifiers = $this->mosp_get_all_identifiers( $email, $phone, $ip, $browser_id ); - - foreach ( $identifiers as $identifier ) { - $block_data = $this->storage->mosp_is_blocked( $identifier ); - if ( $block_data['blocked'] && 'cooldown' === $block_data['reason'] ) { - $now = time(); - $remaining_time = $block_data['blocked_until'] - $now; - if ( $remaining_time > 0 ) { - return array( - 'would_be_blocked' => true, - 'reason' => 'cooldown', - 'remaining_time' => $remaining_time, - ); - } - } - } - - foreach ( $identifiers as $identifier ) { - $cooldown_result = $this->mosp_would_be_on_cooldown_after_attempt_with_details( $identifier, $settings ); - if ( $cooldown_result['would_be_on_cooldown'] ) { - return array( - 'would_be_blocked' => true, - 'reason' => 'cooldown', - 'remaining_time' => $cooldown_result['remaining_time'], - ); - } - } - - if ( $this->mosp_would_exceed_hourly_limit_after_attempt( $email, $phone, $settings ) ) { - $remaining_time = $this->mosp_get_hourly_limit_reset_time( $email, $phone ); - return array( - 'would_be_blocked' => true, - 'reason' => 'hourly_limit_exceeded', - 'remaining_time' => $remaining_time, - ); - } - - if ( $this->mosp_would_exceed_daily_limit_after_attempt( $email, $phone, $settings ) ) { - $remaining_time = $this->mosp_get_daily_limit_reset_time( $email, $phone ); - return array( - 'would_be_blocked' => true, - 'reason' => 'daily_limit_exceeded', - 'remaining_time' => $remaining_time, - ); - } - - foreach ( $identifiers as $identifier ) { - $max_attempts_result = $this->mosp_would_exceed_max_attempts_after_attempt_with_details( $identifier, $settings ); - if ( $max_attempts_result['would_exceed'] ) { - return array( - 'would_be_blocked' => true, - 'reason' => 'max_attempts_exceeded', - 'remaining_time' => $max_attempts_result['remaining_time'], - ); - } - } - - return array( - 'would_be_blocked' => false, - 'reason' => '', - 'remaining_time' => 0, - ); - } - - /** - * Check if recording one more attempt would exceed hourly limit. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param array $settings Plugin settings. - * @return bool True if would exceed hourly limit after attempt, false otherwise - */ - private function mosp_would_exceed_hourly_limit_after_attempt( $email, $phone, $settings ) { - $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); - if ( empty( $identifiers ) ) { - return false; - } - - foreach ( $identifiers as $identifier ) { - if ( $this->mosp_has_completed_limit_puzzle_for_identifier( $identifier ) ) { - continue; - } - $current_attempts = MoRateLimitHelper::mosp_get_hourly_attempts( $identifier ); - if ( ( $current_attempts + 1 ) > $settings['hourly_limit'] ) { - return true; - } - } - - return false; - } - - /** - * Check if recording one more attempt would exceed daily limit. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param array $settings Plugin settings. - * @return bool True if would exceed daily limit after attempt, false otherwise - */ - private function mosp_would_exceed_daily_limit_after_attempt( $email, $phone, $settings ) { - $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); - if ( empty( $identifiers ) ) { - return false; - } - - foreach ( $identifiers as $identifier ) { - if ( $this->mosp_has_completed_limit_puzzle_for_identifier( $identifier ) ) { - continue; - } - $current_attempts = MoRateLimitHelper::mosp_get_daily_attempts( $identifier ); - if ( ( $current_attempts + 1 ) > $settings['daily_limit'] ) { - return true; - } - } - - return false; - } - - /** - * Check if recording one more attempt would exceed max attempts for identifier. - * - * @param string $identifier The identifier to check. - * @param array $settings Plugin settings. - * @return bool True if would exceed max attempts after attempt, false otherwise - */ - private function mosp_would_exceed_max_attempts_after_attempt( $identifier, $settings ) { - $result = $this->mosp_would_exceed_max_attempts_after_attempt_with_details( $identifier, $settings ); - return $result['would_exceed']; - } - - /** - * Check if recording one more attempt would exceed max attempts, with detailed information. - * - * @param string $identifier The identifier to check. - * @param array $settings Plugin settings. - * @return array Array with 'would_exceed' (bool) and 'remaining_time' (int) - */ - private function mosp_would_exceed_max_attempts_after_attempt_with_details( $identifier, $settings ) { - $key = $this->storage->mosp_hash_key( $identifier ); - $data = $this->storage->mosp_get_spam_data( $key ); - $now = time(); - - if ( false === $data || ! isset( $data['attempts'] ) || ! is_array( $data['attempts'] ) || empty( $data['attempts'] ) ) { - return array( - 'would_exceed' => false, - 'remaining_time' => 0, - ); - } - - if ( isset( $data['blocked_until'] ) && $data['blocked_until'] > $now ) { - $block_reason = isset( $data['block_reason'] ) ? $data['block_reason'] : ''; - if ( 'max_attempts_exceeded' === $block_reason ) { - $remaining_time = $data['blocked_until'] - $now; - return array( - 'would_exceed' => true, - 'remaining_time' => $remaining_time, - ); - } - } - - $time_window = MoSecurityHelper::COUNTING_WINDOW_SECONDS; // 15 minutes. - $cutoff_time = $now - $time_window; - $recent_attempts = array(); - - foreach ( $data['attempts'] as $timestamp ) { - if ( $timestamp > $cutoff_time ) { - $recent_attempts[] = $timestamp; - } - } - - $recent_attempts_count = count( $recent_attempts ); - - if ( ( $recent_attempts_count + 1 ) > $settings['max_attempts'] ) { - if ( isset( $data['blocked_until'] ) && $data['blocked_until'] > $now ) { - $block_reason = isset( $data['block_reason'] ) ? $data['block_reason'] : ''; - if ( 'max_attempts_exceeded' === $block_reason ) { - $remaining_time = $data['blocked_until'] - $now; - return array( - 'would_exceed' => true, - 'remaining_time' => $remaining_time, - ); - } - } - - $block_time_seconds = $settings['block_time']; - $remaining_time = $block_time_seconds; - - return array( - 'would_exceed' => true, - 'remaining_time' => $remaining_time, - ); - } - - return array( - 'would_exceed' => false, - 'remaining_time' => 0, - ); - } - - /** - * Check if recording one more attempt would put the identifier on cooldown. - * This checks if there's a recent attempt that would trigger cooldown after adding this attempt. - * - * @param string $identifier The identifier to check. - * @param array $settings Plugin settings. - * @return bool True if would be on cooldown after attempt, false otherwise - */ - private function mosp_would_be_on_cooldown_after_attempt( $identifier, $settings ) { - $result = $this->mosp_would_be_on_cooldown_after_attempt_with_details( $identifier, $settings ); - return $result['would_be_on_cooldown']; - } - - /** - * Check if recording one more attempt would put the identifier on cooldown, with detailed information. - * This checks if there's a recent attempt that would trigger cooldown after adding this attempt. - * - * @param string $identifier The identifier to check. - * @param array $settings Plugin settings. - * @return array Array with 'would_be_on_cooldown' (bool) and 'remaining_time' (int) - */ - private function mosp_would_be_on_cooldown_after_attempt_with_details( $identifier, $settings ) { - $key = $this->storage->mosp_hash_key( $identifier ); - $data = $this->storage->mosp_get_spam_data( $key ); - $now = time(); - - if ( false === $data || ! isset( $data['attempts'] ) || ! is_array( $data['attempts'] ) || empty( $data['attempts'] ) ) { - return array( - 'would_be_on_cooldown' => false, - 'remaining_time' => 0, - ); - } - - $cooldown_time = $settings['cooldown_time']; - $attempts = $data['attempts']; - $attempt_count = count( $attempts ); - - if ( 1 === $attempt_count ) { - $most_recent_attempt = max( $attempts ); - $time_since_most_recent = $now - $most_recent_attempt; - - if ( $time_since_most_recent < $cooldown_time ) { - $remaining_cooldown = $cooldown_time - $time_since_most_recent; - return array( - 'would_be_on_cooldown' => true, - 'remaining_time' => $remaining_cooldown, - ); - } - - return array( - 'would_be_on_cooldown' => false, - 'remaining_time' => 0, - ); - } - $most_recent_attempt = max( $attempts ); - - $time_since_most_recent = $now - $most_recent_attempt; - - if ( $time_since_most_recent < $cooldown_time ) { - $remaining_cooldown = $cooldown_time - $time_since_most_recent; - if ( $remaining_cooldown < 0 ) { - $remaining_cooldown = 0; - } - return array( - 'would_be_on_cooldown' => true, - 'remaining_time' => $remaining_cooldown, - ); - } - - return array( - 'would_be_on_cooldown' => false, - 'remaining_time' => 0, - ); - } - - /** - * Check if a specific identifier is blocked. - * - * @param string $identifier The identifier to check. - * @return bool True if blocked, false otherwise - */ - private function is_identifier_blocked( $identifier ) { - // Use the storage method that contains the complete blocking logic. - $block_data = $this->storage->mosp_is_blocked( $identifier ); - return $block_data['blocked']; - } - - /** - * Record an attempt for a specific identifier. - * - * @param string $identifier The identifier. - * @param int $current_time Current timestamp. - * @param array $context Context array. - */ - private function mosp_record_identifier_attempt( $identifier, $current_time, $context = array() ) { - $this->storage->mosp_record_attempt_with_timestamp( $identifier, $current_time, $context ); - } - - /** - * Check for spam before OTP is sent. - * - * @param bool $allow Whether to allow OTP sending. - * @param string $user_login Username. - * @param string $user_email Email address. - * @param string $phone_number Phone number. - * @return bool|WP_Error - */ - public function mosp_check_spam_before_otp_send( $allow, $user_login, $user_email, $phone_number ) { - if ( ! $this->mosp_is_addon_enabled() ) { - return $allow; - } - - $settings = $this->storage->mosp_get_settings(); - - if ( ! empty( $user_email ) ) { - MoPHPSessions::add_session_var( 'user_email', $user_email ); - } - if ( ! empty( $phone_number ) ) { - MoPHPSessions::add_session_var( 'phone_number_mo', $phone_number ); - } - - $identifiers = $this->mosp_get_request_identifiers( $user_email, $phone_number ); - - foreach ( $identifiers as $type => $identifier ) { - if ( empty( $identifier ) ) { - continue; - } - - if ( $this->storage->mosp_is_whitelisted( $identifier, $type ) ) { - continue; - } - - $block_status = $this->storage->mosp_is_blocked( $identifier ); - - if ( $block_status['blocked'] ) { - return $this->create_block_error( $block_status, $type, $identifier ); - } - } - - return $allow; - } - - /** - * Record OTP attempt after successful send - * - * @param string $user_login Username. - * @param string $user_email Email address. - * @param string $phone_number Phone number. - * @return void - */ - public function mosp_record_otp_attempt( $user_login, $user_email, $phone_number ) { - if ( ! $this->mosp_is_addon_enabled() ) { - return; - } - - $settings = $this->storage->mosp_get_settings(); - $ip = $this->mosp_get_client_ip(); - $browser = $this->get_browser_id(); - - $identifiers = $this->mosp_get_request_identifiers( $user_email, $phone_number ); - $context = array( - 'ip' => $ip, - 'browser_id' => $browser, - 'email' => isset( $identifiers['email'] ) ? $identifiers['email'] : '', - 'phone' => isset( $identifiers['phone'] ) ? $identifiers['phone'] : '', - ); - - foreach ( $identifiers as $type => $identifier ) { - if ( empty( $identifier ) || $this->storage->mosp_is_whitelisted( $identifier, $type ) ) { - continue; - } - - $this->storage->mosp_record_attempt( $identifier, $type, $context ); - } - } - - /** - * Get all identifiers for the current request. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @return array Array of identifiers. - */ - private function mosp_get_request_identifiers( $email, $phone ) { - $settings = $this->storage->mosp_get_settings(); - $identifiers = array(); - - if ( $settings['track_email'] && ! empty( $email ) ) { - $identifiers['email'] = strtolower( trim( $email ) ); - } - - if ( $settings['track_phone'] && ! empty( $phone ) ) { - $identifiers['phone'] = preg_replace( '/[^0-9+]/', '', $phone ); - } - - if ( $settings['track_ip'] ) { - $ip = $this->mosp_get_client_ip(); - if ( $ip ) { - $identifiers['ip'] = $ip; - } - } - - if ( $settings['track_browser'] ) { - $browser_id = $this->get_browser_id(); - if ( $browser_id ) { - $identifiers['browser'] = $browser_id; - } - } - - return $identifiers; - } - - /** - * Get client IP address with anti-spoofing protection - * - * @return string - */ - public function mosp_get_client_ip() { - $ip_candidates = $this->get_ip_candidates(); - - if ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) { - $remote_addr = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ); - if ( filter_var( $remote_addr, FILTER_VALIDATE_IP ) && - ! filter_var( $remote_addr, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { - if ( $this->storage->mosp_is_whitelisted( $remote_addr, 'ip' ) ) { - return $remote_addr; - } - } - } - - $validated_ip = $this->validate_ip_security( $ip_candidates ); - - return $validated_ip; - } - - /** - * Get all possible IP addresses from headers. - * - * @return array Array of IP candidates with their sources - */ - private function get_ip_candidates() { - $candidates = array(); - - $ip_sources = array( - 'REMOTE_ADDR' => array( - 'priority' => 1, - 'spoofable' => false, - ), - 'HTTP_CLIENT_IP' => array( - 'priority' => 2, - 'spoofable' => true, - ), - 'HTTP_CF_CONNECTING_IP' => array( - 'priority' => 3, - 'spoofable' => false, - ), - 'HTTP_X_REAL_IP' => array( - 'priority' => 4, - 'spoofable' => true, - ), - 'HTTP_X_FORWARDED_FOR' => array( - 'priority' => 5, - 'spoofable' => true, - ), - 'HTTP_X_FORWARDED' => array( - 'priority' => 6, - 'spoofable' => true, - ), - 'HTTP_X_CLUSTER_CLIENT_IP' => array( - 'priority' => 7, - 'spoofable' => true, - ), - 'HTTP_FORWARDED_FOR' => array( - 'priority' => 8, - 'spoofable' => true, - ), - 'HTTP_FORWARDED' => array( - 'priority' => 9, - 'spoofable' => true, - ), - ); - - foreach ( $ip_sources as $header => $config ) { - if ( ! empty( $_SERVER[ $header ] ) ) { - $raw_value = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ); - $ips = $this->parse_ip_header( $raw_value ); - - foreach ( $ips as $ip ) { - if ( $this->is_valid_public_ip( $ip ) ) { - $candidates[] = array( - 'ip' => $ip, - 'source' => $header, - 'priority' => $config['priority'], - 'spoofable' => $config['spoofable'], - 'raw_header' => $raw_value, - ); - } - } - } - } - - return $candidates; - } - - /** - * Parse IP header value (handles comma-separated lists). - * - * @param string $header_value Raw header value. - * @return array Array of IP addresses. - */ - private function parse_ip_header( $header_value ) { - $ips = array(); - - if ( strpos( $header_value, ',' ) !== false ) { - $parts = explode( ',', $header_value ); - foreach ( $parts as $part ) { - $ip = trim( $part ); - if ( ! empty( $ip ) ) { - $ips[] = $ip; - } - } - } else { - $ips[] = trim( $header_value ); - } - - return $ips; - } - - /** - * Validate IP with security checks. - * - * @param string $ip IP address to validate. - * @return bool True if valid public IP. - */ - private function is_valid_public_ip( $ip ) { - if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { - return false; - } - - if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { - return false; - } - - if ( $this->is_suspicious_ip( $ip ) ) { - return false; - } - - return true; - } - - /** - * Check if IP appears suspicious. - * - * @param string $ip IP address. - * @return bool True if suspicious. - */ - private function is_suspicious_ip( $ip ) { - $suspicious_patterns = array( - '0.0.0.0', - '255.255.255.255', - '1.1.1.1', - '8.8.8.8', - '127.0.0.1', - '169.254.0.0', - '224.0.0.0', - '240.0.0.0', - ); - - foreach ( $suspicious_patterns as $pattern ) { - if ( strpos( $ip, $pattern ) === 0 ) { - return true; - } - } - - return false; - } - - /** - * Validate IP security and select most trustworthy. - * - * @param array $candidates Array of IP candidates. - * @return string Most trustworthy IP address. - */ - private function validate_ip_security( $candidates ) { - if ( empty( $candidates ) ) { - return ''; - } - - usort( - $candidates, - function ( $a, $b ) { - return $a['priority'] - $b['priority']; - } - ); - - $remote_addr = $this->get_remote_addr_ip( $candidates ); - $proxy_detection = $this->detect_proxy_environment(); - - if ( ! $proxy_detection['behind_proxy'] ) { - return $remote_addr ? $remote_addr : ''; - } - - if ( $proxy_detection['trusted_proxy'] ) { - foreach ( $candidates as $candidate ) { - if ( 'HTTP_CF_CONNECTING_IP' === $candidate['source'] && ! $candidate['spoofable'] ) { - return $candidate['ip']; - } - } - foreach ( $candidates as $candidate ) { - if ( ! $candidate['spoofable'] ) { - return $candidate['ip']; - } - } - } - - return $remote_addr ? $remote_addr : $candidates[0]['ip']; - } - - /** - * Get REMOTE_ADDR IP from candidates. - * - * @param array $candidates IP candidates. - * @return string|null REMOTE_ADDR IP or null. - */ - private function get_remote_addr_ip( $candidates ) { - foreach ( $candidates as $candidate ) { - if ( 'REMOTE_ADDR' === $candidate['source'] ) { - return $candidate['ip']; - } - } - return null; - } - - /** - * Detect proxy environment. - * - * @return array Proxy detection results. - */ - private function detect_proxy_environment() { - - $result = array( - 'behind_proxy' => false, - 'trusted_proxy' => false, - 'proxy_type' => 'none', - ); - - if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) || ! empty( $_SERVER['HTTP_CF_RAY'] ) ) { - $result['behind_proxy'] = true; - $result['trusted_proxy'] = true; - $result['proxy_type'] = 'cloudflare'; - return $result; - } - - $trusted_headers = array( - 'HTTP_CLIENT_IP', - 'HTTP_X_FORWARDED_FOR', - 'HTTP_X_REAL_IP', - ); - - foreach ( $trusted_headers as $header ) { - if ( ! empty( $_SERVER[ $header ] ) ) { - $result['behind_proxy'] = true; - $remote_addr = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; - if ( $this->is_known_proxy_ip( $remote_addr ) ) { - $result['trusted_proxy'] = true; - } - $result['proxy_type'] = 'generic'; - break; - } - } - - return $result; - } - - /** - * Check if IP belongs to known proxy services. - * - * @param string $ip IP address to check. - * @return bool True if known proxy IP - */ - private function is_known_proxy_ip( $ip ) { - if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { - return false; - } - - // Cloudflare IP ranges (simplified check). - $cloudflare_ranges = array( - '173.245.48.0/20', - '103.21.244.0/22', - '103.22.200.0/22', - '103.31.4.0/22', - '141.101.64.0/18', - '108.162.192.0/18', - '190.93.240.0/20', - '188.114.96.0/20', - '197.234.240.0/22', - '198.41.128.0/17', - '162.158.0.0/15', - '104.16.0.0/13', - '104.24.0.0/14', - '172.64.0.0/13', - '131.0.72.0/22', - ); - - foreach ( $cloudflare_ranges as $range ) { - if ( $this->ip_in_range( $ip, $range ) ) { - return true; - } - } - - return false; - } - - /** - * Check if IP is in CIDR range. - * - * @param string $ip IP to check. - * @param string $range CIDR range. - * @return bool True if IP is in range - */ - private function ip_in_range( $ip, $range ) { - if ( strpos( $range, '/' ) === false ) { - return $ip === $range; - } - - list($subnet, $bits) = explode( '/', $range ); - - if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) && filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { - $ip_long = ip2long( $ip ); - $subnet_long = ip2long( $subnet ); - $mask = -1 << ( 32 - (int) $bits ); - $subnet_long &= $mask; - return ( $ip_long & $mask ) === $subnet_long; - } - - return false; - } - - /** - * Detect IP switching attacks. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param string $browser_id Browser ID. - * @param string $current_ip Current IP address. - * @return bool True if attack detected - */ - private function detect_ip_switching_attack( $email, $phone, $browser_id, $current_ip ) { - if ( empty( $current_ip ) ) { - return false; - } - - $tracking_key = ''; - if ( ! empty( $email ) ) { - $tracking_key = 'email:' . $email; - } elseif ( ! empty( $phone ) ) { - $tracking_key = 'phone:' . $phone; - } elseif ( ! empty( $browser_id ) ) { - $tracking_key = 'browser:' . $browser_id; - } - - if ( empty( $tracking_key ) ) { - return false; - } - - $ip_history_key = 'mo_osp_ip_history_' . md5( $tracking_key ); - $ip_history = MoPHPSessions::get_session_var( $ip_history_key ); - - if ( false === $ip_history ) { - $ip_history = array(); - } - - $current_time = time(); - $ip_history[] = array( - 'ip' => $current_ip, - 'timestamp' => $current_time, - ); - - $cutoff_time = $current_time - 600; - $ip_history = array_filter( - $ip_history, - function ( $entry ) use ( $cutoff_time ) { - return $entry['timestamp'] > $cutoff_time; - } - ); - - $unique_ips = array(); - foreach ( $ip_history as $entry ) { - $unique_ips[ $entry['ip'] ] = true; - } - - MoPHPSessions::add_session_var( $ip_history_key, $ip_history ); // 10 minutes - - return count( $unique_ips ) > 3; - } - - /** - * Log security events. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param string $ip IP address. - * @param string $browser_id Browser ID. - * @param string $event_type Event type. - * @return void - */ - private function mosp_log_security_event( $email, $phone, $ip, $browser_id, $event_type = 'OTP_REQUEST' ) { - if ( 'OTP_REQUEST' === $event_type ) { - return; - } - - $log_entry = array( - 'timestamp' => current_time( 'mysql' ), - 'event_type' => $event_type, - 'email' => $email ? wp_hash( $email ) : '', - 'phone' => $phone ? wp_hash( $phone ) : '', - 'ip' => $ip ? wp_hash( $ip ) : '', - 'browser_id' => $browser_id, - 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', //phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized within the function. - 'referer' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '', //phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- esc_url_raw() handles sanitization. - ); - - $log_key = 'mo_osp_security_log'; - $existing_log = get_mo_option( $log_key ); - - if ( is_string( $existing_log ) ) { - $maybe = maybe_unserialize( $existing_log ); - $existing_log = is_array( $maybe ) ? $maybe : array(); - } elseif ( ! is_array( $existing_log ) ) { - $existing_log = array(); - } - - if ( count( $existing_log ) >= 100 ) { - $existing_log = array_slice( $existing_log, -99 ); - } - - $existing_log[] = $log_entry; - update_mo_option( $log_key, $existing_log ); - } - - /** - * Get browser identifier from request. - * - * @return string - */ - private function get_browser_id() { - // phpcs:disable WordPress.Security.NonceVerification.Missing -- Called from OTP generation hook, no nonce available - if ( isset( $_POST['mo_osp_browser_id'] ) ) { //phpcs:ignore WordPress.Security.NonceVerification.Missing -- Called from OTP generation hook, no nonce available - return sanitize_text_field( wp_unslash( $_POST['mo_osp_browser_id'] ) ); //phpcs:ignore WordPress.Security.NonceVerification.Missing -- Sanitized within the function. - } - - $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; //phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized within the function. - if ( $user_agent ) { - return hash( 'sha256', $user_agent ); - } - - return ''; - } - - /** - * Create error for blocked request. - * - * @param array $block_status Block status information. - * @param string $type Identifier type. - * @param string $identifier The identifier. - * @return WP_Error. - */ - private function create_block_error( $block_status, $type, $identifier ) { - $masked_id = $this->storage->mosp_mask_identifier( $identifier, $type ); - - switch ( $block_status['reason'] ) { - case 'cooldown': - $message = sprintf( - /* translators: %1$s: masked identifier, %2$d: remaining seconds */ - __( 'Please wait %2$d seconds before requesting another OTP for %1$s.', 'miniorange-otp-verification' ), - $masked_id, - $block_status['remaining'] - ); - break; - - case 'max_attempts_exceeded': - $blocked_until = date_i18n( get_mo_option( 'time_format' ), $block_status['blocked_until'] ); - $message = sprintf( - /* translators: %1$s: masked identifier, %2$s: time when block expires */ - MoMessages::showMessage( MoMessages::USER_IS_BLOCKED_AJAX ), - $masked_id, - $blocked_until - ); - break; - - case 'temporarily_blocked': - $blocked_until = date_i18n( get_mo_option( 'time_format' ), $block_status['blocked_until'] ); - $message = sprintf( - /* translators: %1$s: masked identifier, %2$s: time when block expires */ - __( 'Access temporarily blocked for %1$s. Please try again after %2$s.', 'miniorange-otp-verification' ), - $masked_id, - $blocked_until - ); - break; - - default: - $message = __( 'OTP request blocked due to spam prevention measures.', 'miniorange-otp-verification' ); - } - - return new \WP_Error( 'otp_spam_blocked', $message ); - } - - /** - * Check if user requires puzzle verification. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param string $ip IP address. - * @param string $browser_id Browser fingerprint. - * @return bool. - */ - public function mosp_requires_puzzle_verification( $email, $phone, $ip, $browser_id ) { - if ( ! $this->mosp_is_addon_enabled() ) { - return false; - } - - return $this->storage->mosp_is_puzzle_required_for_user( $email, $phone, $ip, $browser_id ); - } - - /** - * Clear puzzle requirement after successful verification. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param string $ip IP address. - * @param string $browser_id Browser fingerprint. - * @return void. - */ - public function mosp_clear_puzzle_requirements( $email, $phone, $ip, $browser_id ) { - $to_clear = array(); - - $raw_email = trim( (string) $email ); - $norm_email = $this->mosp_normalize_email_for_spam( $email ); - if ( '' !== $raw_email ) { - $to_clear[] = $raw_email; - } - if ( '' !== $norm_email && $norm_email !== $raw_email ) { - $to_clear[] = $norm_email; - } - - $raw_phone = trim( (string) $phone ); - $norm_phone = $this->mosp_normalize_phone_for_spam( $phone ); - if ( '' !== $raw_phone ) { - $to_clear[] = $raw_phone; - } - if ( '' !== $norm_phone && $norm_phone !== $raw_phone ) { - $to_clear[] = $norm_phone; - } - - if ( ! empty( $ip ) ) { - $to_clear[] = $ip; - } - if ( ! empty( $browser_id ) ) { - $to_clear[] = $browser_id; - } - - foreach ( array_unique( array_filter( $to_clear ) ) as $identifier ) { - $this->storage->mosp_clear_puzzle_requirement( $identifier ); - } - } - - /** - * Check if user has completed puzzle verification for hourly/daily limits. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @return bool True if puzzle was completed - */ - public function mosp_has_completed_limit_puzzle( $email, $phone ) { - if ( ! $this->mosp_is_addon_enabled() ) { - return false; - } - - $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); - if ( empty( $identifiers ) ) { - return false; - } - - foreach ( $identifiers as $identifier ) { - if ( ! $this->mosp_has_completed_limit_puzzle_for_identifier( $identifier ) ) { - return false; - } - } - - return true; - } - - /** - * Mark that user has completed puzzle verification for hourly/daily limits. - * - * @param string $email Email address. - * @param string $phone Phone number. - */ - public function mosp_mark_limit_puzzle_completed( $email, $phone ) { - $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); - if ( empty( $identifiers ) ) { - return; - } - - foreach ( $identifiers as $identifier ) { - $puzzle_key = 'limit_puzzle_' . $this->storage->mosp_hash_key( $identifier ); - MoPHPSessions::add_session_var( $puzzle_key, 'completed' ); - - $permanent_key = 'puzzle_ever_completed_' . $this->storage->mosp_hash_key( $identifier ); - update_option( $permanent_key, time() ); - - MoRateLimitHelper::mosp_clear_rate_limit( $identifier, 'hourly' ); - MoRateLimitHelper::mosp_clear_rate_limit( $identifier, 'daily' ); - } - - $this->mosp_reset_immediate_spam_protection( $email, $phone ); - } - - /** - * Reset immediate spam protection after puzzle completion. - * - * This clears cooldown timers, attempt counts in the 15-minute window, and blocks. - * Note: Daily/hourly rate limits are cleared separately in mosp_mark_limit_puzzle_completed(). - * - * @param string $email Email address. - * @param string $phone Phone number. - */ - public function mosp_reset_immediate_spam_protection( $email, $phone ) { - $ip = $this->mosp_get_client_ip(); - $browser_id = $this->get_browser_id(); - - $identifiers = $this->mosp_get_identifiers_to_reset_on_puzzle_success( $email, $phone, $ip, $browser_id ); - - foreach ( $identifiers as $identifier ) { - $key = $this->storage->mosp_hash_key( $identifier ); - $data = $this->storage->mosp_get_spam_data( $key ); - - if ( false !== $data ) { - $data['attempts'] = array(); - $data['blocked_until'] = 0; - $data['last_attempt'] = 0; - if ( isset( $data['block_count'] ) ) { - $data['block_count'] = 0; - } - if ( isset( $data['block_reason'] ) ) { - $data['block_reason'] = ''; - } - - $this->storage->mosp_update_spam_data( $key, $data ); - } - } - - $this->mosp_clear_puzzle_requirements( $email, $phone, $ip, $browser_id ); - } - - /** - * Check if user requires puzzle verification for hourly/daily limits. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @return bool True if puzzle is required - */ - public function mosp_requires_limit_puzzle_verification( $email, $phone ) { - if ( ! $this->mosp_is_addon_enabled() ) { - return false; - } - - $settings = $this->storage->mosp_get_settings(); - $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); - if ( empty( $identifiers ) ) { - return false; - } - - $ip = $this->mosp_get_client_ip(); - $identifiers = $this->mosp_get_all_identifiers( $email, $phone, $ip, '' ); - - foreach ( $identifiers as $identifier ) { - $block_data = $this->storage->mosp_is_blocked( $identifier ); - if ( $block_data['blocked'] ) { - return false; - } - } - - $daily_exceeded = false; - $hourly_exceeded = false; - $max_attempts_exceeded = false; - - foreach ( $identifiers as $identifier ) { - if ( $this->mosp_has_completed_limit_puzzle_for_identifier( $identifier ) ) { - continue; - } - $daily_attempts = MoRateLimitHelper::mosp_get_daily_attempts( $identifier ); - $hourly_attempts = MoRateLimitHelper::mosp_get_hourly_attempts( $identifier ); - - if ( $daily_attempts >= $settings['daily_limit'] ) { - $daily_exceeded = true; - } - if ( $hourly_attempts >= $settings['hourly_limit'] ) { - $hourly_exceeded = true; - } - if ( $daily_exceeded || $hourly_exceeded ) { - break; - } - } - - $ip = $this->mosp_get_client_ip(); - $all_identifiers = $this->mosp_get_all_identifiers( $email, $phone, $ip, '' ); - - foreach ( $all_identifiers as $identifier ) { - $identifier_data = $this->storage->mosp_get_spam_data( $this->storage->mosp_hash_key( $identifier ) ); - if ( false !== $identifier_data && isset( $identifier_data['attempts'] ) && is_array( $identifier_data['attempts'] ) ) { - $time_window = MoSecurityHelper::COUNTING_WINDOW_SECONDS; // 15 minutes - $cutoff_time = time() - $time_window; - $recent_attempts = 0; - - foreach ( $identifier_data['attempts'] as $timestamp ) { - if ( $timestamp > $cutoff_time ) { - ++$recent_attempts; - } - } - - if ( $recent_attempts > $settings['max_attempts'] ) { - $max_attempts_exceeded = true; - break; - } - } - } - - $requires_puzzle = $daily_exceeded || $hourly_exceeded || $max_attempts_exceeded; - - return $requires_puzzle; - } - - /** - * Check if daily OTP limit is exceeded for a user using sliding window. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param array $settings Settings array. - * @param string $context Context of the check ('otp_send' or 'timer_status'). - * @return bool True if daily limit exceeded - */ - private function mosp_is_daily_limit_exceeded( $email, $phone, $settings, $context = 'otp_send' ) { - $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); - if ( empty( $identifiers ) ) { - return false; - } - - foreach ( $identifiers as $identifier ) { - if ( 'otp_send' === $context && $this->mosp_has_completed_limit_puzzle_for_identifier( $identifier ) ) { - continue; - } - if ( MoRateLimitHelper::mosp_is_daily_limit_exceeded( $identifier, $settings['daily_limit'] ) ) { - return true; - } - } - - return false; - } - - /** - * Get remaining time until daily limit resets (sliding window). - * - * @param string $email Email address. - * @param string $phone Phone number. - * @return int Remaining seconds until oldest attempt expires. - */ - private function mosp_get_daily_limit_reset_time( $email, $phone ) { - $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); - if ( empty( $identifiers ) ) { - return 0; - } - - $max_remaining = 0; - foreach ( $identifiers as $identifier ) { - $remaining = MoRateLimitHelper::mosp_get_reset_time( $identifier, MoRateLimitHelper::DAILY_WINDOW, 'daily' ); - if ( $remaining > $max_remaining ) { - $max_remaining = $remaining; - } - } - - return $max_remaining; - } - - /** - * Get remaining time until hourly limit resets (sliding window). - * - * @param string $email Email address. - * @param string $phone Phone number. - * @return int Remaining seconds until oldest attempt expires. - */ - private function mosp_get_hourly_limit_reset_time( $email, $phone ) { - $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); - if ( empty( $identifiers ) ) { - return 0; - } - - $max_remaining = 0; - foreach ( $identifiers as $identifier ) { - $remaining = MoRateLimitHelper::mosp_get_reset_time( $identifier, MoRateLimitHelper::HOURLY_WINDOW, 'hourly' ); - if ( $remaining > $max_remaining ) { - $max_remaining = $remaining; - } - } - - return $max_remaining; - } - - /** - * Check if hourly OTP limit is exceeded for a user using sliding window. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param array $settings Settings array. - * @param string $context Context of the check ('otp_send' or 'timer_status'). - * @return bool True if hourly limit exceeded - */ - private function mosp_is_hourly_limit_exceeded( $email, $phone, $settings, $context = 'otp_send' ) { - $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); - if ( empty( $identifiers ) ) { - return false; - } - - foreach ( $identifiers as $identifier ) { - if ( 'otp_send' === $context && $this->mosp_has_completed_limit_puzzle_for_identifier( $identifier ) ) { - continue; - } - if ( MoRateLimitHelper::mosp_is_hourly_limit_exceeded( $identifier, $settings['hourly_limit'] ) ) { - return true; - } - } - - return false; - } - - /** - * Get user identifier (email or phone, whichever is available). - * - * @param string $email Email address. - * @param string $phone Phone number. - * @return string User identifier - */ - private function mosp_get_user_identifier( $email, $phone ) { - $np = $this->mosp_normalize_phone_for_spam( $phone ); - if ( '' !== $np ) { - return 'phone:' . $np; - } - $ne = $this->mosp_normalize_email_for_spam( $email ); - if ( '' !== $ne ) { - return 'email:' . $ne; - } - return ''; - } - - /** - * Get identifiers used for hourly/daily limits. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @return array - */ - private function mosp_get_limit_identifiers( $email, $phone ) { - $settings = $this->storage->mosp_get_settings(); - $identifiers = array(); - - if ( $settings['track_phone'] && ! empty( $phone ) ) { - $np = $this->mosp_normalize_phone_for_spam( $phone ); - if ( '' !== $np ) { - $identifiers[] = 'phone:' . $np; - } - return $identifiers; - } - - if ( $settings['track_email'] && ! empty( $email ) ) { - $ne = $this->mosp_normalize_email_for_spam( $email ); - if ( '' !== $ne ) { - $identifiers[] = 'email:' . $ne; - } - } - - return $identifiers; - } - - /** - * Check if user has completed limit puzzle verification for a single identifier. - * - * @param string $identifier Identifier for limit checks. - * @return bool - */ - private function mosp_has_completed_limit_puzzle_for_identifier( $identifier ) { - if ( empty( $identifier ) ) { - return false; - } - - $puzzle_key = 'limit_puzzle_' . $this->storage->mosp_hash_key( $identifier ); - $completed = MoPHPSessions::get_session_var( $puzzle_key ); - - return 'completed' === $completed; - } - - /** - * Record daily and hourly attempts for a user using sliding window. - * - * @param string $email Email address. - * @param string $phone Phone number. - */ - private function mosp_record_daily_hourly_attempts( $email, $phone ) { - $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); - if ( empty( $identifiers ) ) { - return; - } - - foreach ( $identifiers as $identifier ) { - MoRateLimitHelper::mosp_record_attempt_multi_window( $identifier ); - } - } - - /** - * Clear hourly limit for a user (for testing purposes). - * - * Usage: Call this method via WordPress admin or add to functions.php: - * $handler = OSP\Handler\MoOtpSpamPreventerHandler::instance(); - * $handler->mosp_clear_hourly_limit('test@example.com', ''); - * - * Or via database: - * DELETE FROM wp_options WHERE option_name LIKE 'mo_customer_validation_mo_osp_rate_limit_hourly_%'; - * - * @param string $email Email address. - * @param string $phone Phone number. - * @return bool True if cleared successfully. - */ - public function mosp_clear_hourly_limit( $email = '', $phone = '' ) { - $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); - if ( empty( $identifiers ) ) { - global $wpdb; - $prefix = 'mo_customer_validation_mo_osp_rate_limit_hourly_'; - $cache_key = 'mosp_hourly_limit_options'; - $option_names = wp_cache_get( $cache_key, 'options' ); - - if ( false === $option_names ) { - $option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->prepare( - "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", - $wpdb->esc_like( $prefix ) . '%' - ) - ); - wp_cache_set( $cache_key, $option_names, 'options' ); - } - - if ( empty( $option_names ) ) { - return false; - } - - foreach ( $option_names as $option_name ) { - delete_option( $option_name ); - } - - wp_cache_delete( $cache_key, 'options' ); - return true; - } - - $cleared = true; - foreach ( $identifiers as $identifier ) { - if ( ! MoRateLimitHelper::mosp_clear_rate_limit( $identifier, 'hourly' ) ) { - $cleared = false; - } - } - return $cleared; - } - - /** - * Check if addon is enabled. - * - * @return bool - */ - private function mosp_is_addon_enabled() { - $settings = $this->storage->mosp_get_settings(); - return ! empty( $settings['enabled'] ); - } - - /** - * Unblock a user by identifier hash. - * - * @param string $identifier_hash The hashed identifier. - * @return array Result with 'success' and 'message' keys. - */ - public function mosp_unblock_user_by_hash( $identifier_hash ) { - if ( empty( $identifier_hash ) ) { - return array( - 'success' => false, - 'message' => __( 'Invalid identifier hash.', 'miniorange-otp-verification' ), - ); - } - - // The identifier_hash is already the hash, so use it directly. - $key = $identifier_hash; - $data = $this->storage->mosp_get_spam_data( $key ); - $blocked_until = 0; - $block_reason = ''; - - if ( false !== $data ) { - $blocked_until = isset( $data['blocked_until'] ) ? (int) $data['blocked_until'] : 0; - $block_reason = isset( $data['block_reason'] ) ? $data['block_reason'] : ''; - - // Clear block status. - $data['blocked_until'] = 0; - $data['block_reason'] = ''; - $data['attempts'] = array(); - $data['last_attempt'] = 0; - - $this->storage->mosp_update_spam_data( $key, $data ); - - $related_identifiers = $this->mosp_build_related_identifiers( $data ); - $this->mosp_clear_identifiers_data( $related_identifiers ); - } - - // Clear rate limit data for all window types using the helper. - $window_types = array( 'hourly', 'daily' ); - foreach ( $window_types as $window_type ) { - $rate_key = 'rate_limit_' . $window_type . '_' . $identifier_hash; - $this->storage->mosp_delete_spam_data( $rate_key ); - } - - // Clear puzzle requirements. - $this->storage->mosp_clear_puzzle_requirement( $identifier_hash ); - - // Clear cache. - wp_cache_delete( 'mosp_blocked_users_list', 'mo_osp' ); - wp_cache_delete( 'mosp_rate_limit_hourly_options', 'mo_osp' ); - wp_cache_delete( 'mosp_rate_limit_daily_options', 'mo_osp' ); - wp_cache_delete( 'mosp_spam_data_option_names', 'mo_osp' ); - - $this->mosp_clear_related_blocks_by_reason( $blocked_until, $block_reason, $identifier_hash ); - - return array( - 'success' => true, - 'message' => __( 'User unblocked successfully.', 'miniorange-otp-verification' ), - ); - } - - /** - * Clear all blocked-user data, rate limits, and puzzle flags (admin only). - * - * @return array{ success: bool, message: string, deleted: int } - */ - public function mosp_clear_all_blocked_data() { - $deleted = $this->storage->mosp_clear_all_otp_spam_data(); - - if ( 0 === $deleted ) { - return array( - 'success' => false, - 'deleted' => 0, - 'message' => __( 'No entries found to clear.', 'miniorange-otp-verification' ), - ); - } - - return array( - 'success' => true, - 'deleted' => $deleted, - 'message' => sprintf( - /* translators: %d: number of database options removed */ - _n( - 'Cleared %d stored entry (blocks, rate limits, and puzzle flags).', - 'Cleared %d stored entries (blocks, rate limits, and puzzle flags).', - $deleted, - 'miniorange-otp-verification' - ), - $deleted - ), - ); - } - - /** - * Build related identifiers from stored metadata. - * - * @param array $data Spam data. - * @return array - */ - private function mosp_build_related_identifiers( $data ) { - $related_identifiers = array(); - if ( isset( $data['last_ip'] ) && filter_var( $data['last_ip'], FILTER_VALIDATE_IP ) ) { - $related_identifiers[] = $data['last_ip']; - $related_identifiers[] = 'ip:' . $data['last_ip']; - } - if ( isset( $data['last_browser'] ) && ! empty( $data['last_browser'] ) ) { - $related_identifiers[] = $data['last_browser']; - $related_identifiers[] = 'browser:' . $data['last_browser']; - } - if ( isset( $data['last_email'] ) && ! empty( $data['last_email'] ) ) { - $related_identifiers[] = $data['last_email']; - $related_identifiers[] = 'email:' . $data['last_email']; - } - if ( isset( $data['last_phone'] ) && ! empty( $data['last_phone'] ) ) { - $related_identifiers[] = $data['last_phone']; - $related_identifiers[] = 'phone:' . $data['last_phone']; - } - - $last_ip = isset( $data['last_ip'] ) ? $data['last_ip'] : ''; - $last_email = isset( $data['last_email'] ) ? $data['last_email'] : ''; - $last_phone = isset( $data['last_phone'] ) ? $data['last_phone'] : ''; - $last_browser = isset( $data['last_browser'] ) ? $data['last_browser'] : ''; - - if ( $last_ip && $last_email ) { - $related_identifiers[] = 'cross_ip_email:' . $last_ip . '|' . $last_email; - } - if ( $last_ip && $last_phone ) { - $related_identifiers[] = 'cross_ip_phone:' . $last_ip . '|' . $last_phone; - } - if ( $last_ip && $last_browser ) { - $related_identifiers[] = 'cross_ip_browser:' . $last_ip . '|' . $last_browser; - } - - return array_values( array_unique( array_filter( $related_identifiers ) ) ); - } - - /** - * Clear spam + rate-limit data for identifiers. - * - * @param array $identifiers Identifiers to clear. - * @return void - */ - private function mosp_clear_identifiers_data( $identifiers ) { - if ( empty( $identifiers ) || ! is_array( $identifiers ) ) { - return; - } - - foreach ( $identifiers as $identifier ) { - if ( empty( $identifier ) ) { - continue; - } - $hash = $this->storage->mosp_hash_key( $identifier ); - - $identifier_data = $this->storage->mosp_get_spam_data( $hash ); - if ( false !== $identifier_data ) { - $identifier_data['blocked_until'] = 0; - $identifier_data['block_reason'] = ''; - $identifier_data['attempts'] = array(); - $identifier_data['last_attempt'] = 0; - $this->storage->mosp_update_spam_data( $hash, $identifier_data ); - } - - $window_types = array( 'hourly', 'daily' ); - foreach ( $window_types as $window_type ) { - $this->storage->mosp_delete_spam_data( 'rate_limit_' . $window_type . '_' . $hash ); - delete_mo_option( 'mo_osp_rate_limit_' . $window_type . '_' . $hash ); - } - - $this->storage->mosp_clear_puzzle_requirement( $hash ); - } - } - - /** - * Clear blocks that share the same block reason and time. - * - * @param int $blocked_until Blocked until timestamp. - * @param string $block_reason Block reason. - * @param string $exclude_hash Identifier hash to skip. - * @return void - */ - private function mosp_clear_related_blocks_by_reason( $blocked_until, $block_reason, $exclude_hash ) { - if ( empty( $blocked_until ) || empty( $block_reason ) ) { - return; - } - - global $wpdb; - - $option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->prepare( - "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", - $wpdb->esc_like( 'mo_customer_validation_' . MoOtpSpamStorage::SPAM_DATA_PREFIX ) . '%' - ) - ); - - if ( empty( $option_names ) ) { - return; - } - - foreach ( $option_names as $db_option_name ) { - $option_key = str_replace( 'mo_customer_validation_', '', $db_option_name ); - $hash_key = str_replace( MoOtpSpamStorage::SPAM_DATA_PREFIX, '', $option_key ); - - if ( $hash_key === $exclude_hash ) { - continue; - } - - $spam_data = $this->storage->mosp_get_spam_data( $hash_key ); - if ( false === $spam_data ) { - continue; - } - - $spam_blocked_until = isset( $spam_data['blocked_until'] ) ? (int) $spam_data['blocked_until'] : 0; - $spam_block_reason = isset( $spam_data['block_reason'] ) ? $spam_data['block_reason'] : ''; - - if ( $spam_blocked_until !== (int) $blocked_until || $spam_block_reason !== $block_reason ) { - continue; - } - - $spam_data['blocked_until'] = 0; - $spam_data['block_reason'] = ''; - $spam_data['attempts'] = array(); - $spam_data['last_attempt'] = 0; - $this->storage->mosp_update_spam_data( $hash_key, $spam_data ); - - $window_types = array( 'hourly', 'daily' ); - foreach ( $window_types as $window_type ) { - $rate_key = 'rate_limit_' . $window_type . '_' . $hash_key; - $this->storage->mosp_delete_spam_data( $rate_key ); - } - - $this->storage->mosp_clear_puzzle_requirement( $hash_key ); - } - } - } -} +<?php +/** + * OTP Spam Preventer Main Handler + * + * @package otpspampreventer/handler + */ + +namespace OSP\Handler; + +use OSP\Handler\MoOtpSpamStorage; +use OSP\Helper\MoRateLimitHelper; +use OSP\Helper\MoSecurityHelper; +use OSP\Traits\Instance; +use OTP\Helper\MoMessages; +use OTP\Helper\MoPHPSessions; +use OTP\Helper\MoUtility; + + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +if ( ! class_exists( 'MoOtpSpamPreventerHandler' ) ) { + /** + * The main handler class for OTP Spam Prevention functionality. + * Integrates with the existing OTP verification flow. + * Updated: Fixed block expiration logic + */ + class MoOtpSpamPreventerHandler { + + use Instance; + + /** + * Storage instance + * + * @var MoOtpSpamStorage + */ + private $storage; + + /** + * Constructor + */ + public function __construct() { + $this->storage = MoOtpSpamStorage::instance(); + MoRateLimitHelper::init( $this->storage ); + } + + /** + * Save settings from POST data. + * + * @param array $posted POST data array. + * @return array Result array with 'success' and 'message' keys. + */ + public function mosp_save_settings( $posted ) { + $settings = array(); + $settings['enabled'] = isset( $posted['mo_osp_enabled'] ); + $settings['cooldown_time'] = isset( $posted['mo_osp_cooldown_time'] ) ? absint( $posted['mo_osp_cooldown_time'] ) : 60; + + $max_attempts = isset( $posted['mo_osp_max_attempts'] ) ? absint( $posted['mo_osp_max_attempts'] ) : 3; + $settings['max_attempts'] = max( 1, min( 10, $max_attempts ) ); + + $settings['block_time'] = isset( $posted['mo_osp_block_time'] ) ? absint( $posted['mo_osp_block_time'] ) : 900; + + $settings['daily_limit'] = isset( $posted['mo_osp_daily_limit'] ) ? absint( $posted['mo_osp_daily_limit'] ) : 10; + $settings['hourly_limit'] = isset( $posted['mo_osp_hourly_limit'] ) ? absint( $posted['mo_osp_hourly_limit'] ) : 5; + + $validation_errors = array(); + + if ( $settings['hourly_limit'] <= $settings['max_attempts'] ) { + $validation_errors[] = 'Hourly limit (' . $settings['hourly_limit'] . ') must be greater than max attempts per window (' . $settings['max_attempts'] . ')'; + } + + if ( $settings['daily_limit'] <= $settings['hourly_limit'] ) { + $validation_errors[] = 'Daily limit (' . $settings['daily_limit'] . ') must be greater than hourly limit (' . $settings['hourly_limit'] . ')'; + } + + if ( ! empty( $validation_errors ) ) { + $error_message = 'Settings validation failed: ' . implode( '; ', $validation_errors ); + do_action( 'mo_otp_verification_show_message', $error_message, 'ERROR' ); + return array( + 'success' => false, + 'message' => $error_message, + ); + } + + $settings['track_phone'] = true; + $settings['track_email'] = true; + $settings['track_ip'] = true; + $settings['track_browser'] = true; + + // Process whitelists. + $whitelist_ips = isset( $posted['mo_osp_whitelist_ips'] ) ? sanitize_textarea_field( wp_unslash( $posted['mo_osp_whitelist_ips'] ) ) : ''; + $settings['whitelist_ips'] = array_filter( array_map( 'trim', explode( "\n", $whitelist_ips ) ) ); + $settings['whitelist_ips'] = array_values( $settings['whitelist_ips'] ); + + $result = $this->storage->mosp_update_settings( $settings ); + + if ( $result ) { + return array( + 'success' => true, + 'message' => __( 'Settings saved successfully!', 'miniorange-otp-verification' ), + ); + } else { + return array( + 'success' => false, + 'message' => __( 'Failed to save settings!', 'miniorange-otp-verification' ), + ); + } + } + + /** + * Check if identifier is whitelisted. + * + * @param string $identifier The identifier to check (IP, email, phone, etc.). + * @param string $type The type of identifier (ip, email, phone). + * @return bool True if whitelisted, false otherwise. + */ + public function mosp_is_whitelisted( $identifier, $type ) { + return $this->storage->mosp_is_whitelisted( $identifier, $type ); + } + + /** + * Check if a request should be blocked due to spam prevention rules + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param string $browser_id Browser fingerprint ID. + * @param string $context Context of the check ('otp_send' or 'timer_status'). + * @return bool True if blocked, false if allowed + */ + public function mosp_is_blocked( $email, $phone, $browser_id = '', $context = 'otp_send' ) { + if ( ! $this->mosp_is_addon_enabled() ) { + return false; + } + + $settings = $this->storage->mosp_get_settings(); + + $ip = $this->mosp_get_client_ip(); + + if ( ! empty( $ip ) ) { + $is_whitelisted = $this->storage->mosp_is_whitelisted( $ip, 'ip' ); + if ( $is_whitelisted ) { + return false; + } + } + + $this->mosp_log_security_event( $email, $phone, $ip, $browser_id ); + + $ip_switching_detected = $this->detect_ip_switching_attack( $email, $phone, $browser_id, $ip ); + if ( $ip_switching_detected ) { + $this->mosp_log_security_event( $email, $phone, $ip, $browser_id, 'IP_SWITCHING_DETECTED' ); + return true; + } + + $daily_limit_exceeded = $this->mosp_is_daily_limit_exceeded( $email, $phone, $settings, $context ); + if ( $daily_limit_exceeded ) { + return true; + } + + $hourly_limit_exceeded = $this->mosp_is_hourly_limit_exceeded( $email, $phone, $settings, $context ); + if ( $hourly_limit_exceeded ) { + return true; + } + + $identifiers = $this->mosp_get_all_identifiers( $email, $phone, $ip, $browser_id ); + + foreach ( $identifiers as $identifier ) { + $identifier_blocked = $this->is_identifier_blocked( $identifier ); + if ( $identifier_blocked ) { + return true; + } + } + + $cross_identifier_blocked = $this->mosp_is_cross_identifier_blocked( $email, $phone, $ip, $browser_id ); + if ( $cross_identifier_blocked ) { + return true; + } + + return false; + } + + /** + * Record an OTP attempt for rate limiting (new method for integration) + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param string $browser_id Browser fingerprint ID. + * @return void + */ + public function mosp_record_attempt_for_identifiers( $email, $phone, $browser_id = '' ) { + if ( ! $this->mosp_is_addon_enabled() ) { + return; + } + + $settings = $this->storage->mosp_get_settings(); + + $ip = $this->mosp_get_client_ip(); + + $identifiers = $this->mosp_get_all_identifiers( $email, $phone, $ip, $browser_id ); + + $current_time = time(); + $context = array( + 'ip' => $ip, + 'browser_id' => $browser_id, + 'email' => $email, + 'phone' => $phone, + ); + + foreach ( $identifiers as $identifier ) { + $this->mosp_record_identifier_attempt( $identifier, $current_time, $context ); + } + + $this->mosp_record_cross_identifier_attempt( $email, $phone, $ip, $browser_id, $current_time, $context ); + + $this->mosp_record_daily_hourly_attempts( $email, $phone ); + } + + /** + * Store a block for all identifiers when a block is detected via "would be blocked" check. + * This ensures the block persists in the database so the timer doesn't reset on each click. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param string $browser_id Browser fingerprint ID. + * @param string $block_reason Reason for the block (e.g., 'max_attempts_exceeded', 'cooldown'). + * @param int $remaining_time Remaining time in seconds until block expires. + * @return void + */ + public function mosp_store_block_for_identifiers( $email, $phone, $browser_id, $block_reason, $remaining_time ) { + $ip = $this->mosp_get_client_ip(); + $identifiers = $this->mosp_get_all_identifiers( $email, $phone, $ip, $browser_id ); + $now = time(); + $blocked_until = $now + $remaining_time; + + // Extract identifier type and value for storage. + $identifier_type_map = array(); + foreach ( $identifiers as $identifier ) { + if ( strpos( $identifier, 'email:' ) === 0 ) { + $identifier_type_map[ $identifier ] = array( + 'type' => 'email', + 'value' => substr( $identifier, 6 ), + ); + } elseif ( strpos( $identifier, 'phone:' ) === 0 ) { + $identifier_type_map[ $identifier ] = array( + 'type' => 'phone', + 'value' => substr( $identifier, 6 ), + ); + } elseif ( strpos( $identifier, 'ip:' ) === 0 ) { + $identifier_type_map[ $identifier ] = array( + 'type' => 'ip', + 'value' => substr( $identifier, 3 ), + ); + } elseif ( strpos( $identifier, 'browser:' ) === 0 ) { + $identifier_type_map[ $identifier ] = array( + 'type' => 'browser', + 'value' => substr( $identifier, 8 ), + ); + } else { + $identifier_type_map[ $identifier ] = array( + 'type' => 'unknown', + 'value' => $identifier, + ); + } + } + + foreach ( $identifiers as $identifier ) { + $key = $this->storage->mosp_hash_key( $identifier ); + $data = $this->storage->mosp_get_spam_data( $key ); + + $id_info = isset( $identifier_type_map[ $identifier ] ) ? $identifier_type_map[ $identifier ] : array( + 'type' => 'unknown', + 'value' => '', + ); + + if ( false === $data ) { + $data = array( + 'type' => $id_info['type'], + 'identifier' => $id_info['value'], // Store original identifier value. + 'attempts' => array(), + 'blocked_until' => 0, + 'total_blocks' => 0, + 'created' => $now, + 'last_attempt' => $now, + ); + } else { + // Update type if not set or is 'identifier' or 'unknown'. + if ( ! isset( $data['type'] ) || 'identifier' === $data['type'] || 'unknown' === $data['type'] ) { + $data['type'] = $id_info['type']; + } + // Store original identifier value if not set. + if ( ! isset( $data['identifier'] ) || empty( $data['identifier'] ) ) { + $data['identifier'] = $id_info['value']; + } + } + + if ( ! isset( $data['blocked_until'] ) || $data['blocked_until'] < $blocked_until ) { + $old_blocked_until = isset( $data['blocked_until'] ) ? $data['blocked_until'] : 0; + $data['blocked_until'] = $blocked_until; + $data['block_reason'] = $block_reason; + + // Store original identifier value for display (admin-only access, no masking needed). + if ( ! empty( $id_info['value'] ) ) { + $data['identifier'] = $id_info['value']; + } + + $this->storage->mosp_update_spam_data( $key, $data ); + } + } + } + + /** + * Get all identifiers for the current request. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param string $ip IP address. + * @param string $browser_id Browser fingerprint ID. + * @return array Array of identifiers + */ + public function mosp_get_all_identifiers( $email, $phone, $ip, $browser_id ) { + $identifiers = array(); + + $norm_email = $this->mosp_normalize_email_for_spam( $email ); + if ( '' !== $norm_email ) { + $identifiers[] = 'email:' . $norm_email; + } + + $norm_phone = $this->mosp_normalize_phone_for_spam( $phone ); + if ( '' !== $norm_phone ) { + $identifiers[] = 'phone:' . $norm_phone; + } + + if ( ! empty( $ip ) ) { + $identifiers[] = 'ip:' . $ip; + } + + if ( ! empty( $browser_id ) ) { + $identifiers[] = 'browser:' . $browser_id; + } + + return $identifiers; + } + + /** + * Normalize email for spam/rate-limit keys (stable casing). + * + * @param string $email Email. + * @return string + */ + private function mosp_normalize_email_for_spam( $email ) { + return strtolower( trim( (string) $email ) ); + } + + /** + * Normalize phone the same way as puzzle verification (MoUtility) so reset/clear hits the same DB rows as OTP send. + * + * @param string $phone Phone. + * @return string + */ + private function mosp_normalize_phone_for_spam( $phone ) { + $phone = trim( (string) $phone ); + if ( '' === $phone ) { + return ''; + } + if ( class_exists( '\OTP\Helper\MoUtility' ) ) { + $processed = MoUtility::process_phone_number( $phone ); + $digits = preg_replace( '/\D/', '', (string) $processed ); + if ( strlen( $digits ) >= 6 ) { + return $processed; + } + } + return preg_replace( '/[^0-9+]/', '', $phone ); + } + + /** + * Cross-identifier strings (IP + credential) using normalized email/phone. + * + * @param string $email Email. + * @param string $phone Phone. + * @param string $ip IP. + * @param string $browser_id Browser id. + * @return string[] + */ + private function mosp_build_cross_identifier_strings( $email, $phone, $ip, $browser_id ) { + if ( empty( $ip ) ) { + return array(); + } + $cross = array(); + $ne = $this->mosp_normalize_email_for_spam( $email ); + if ( '' !== $ne ) { + $cross[] = 'cross_ip_email:' . $ip . '|' . $ne; + } + $np = $this->mosp_normalize_phone_for_spam( $phone ); + if ( '' !== $np ) { + $cross[] = 'cross_ip_phone:' . $ip . '|' . $np; + } + if ( ! empty( $browser_id ) ) { + $cross[] = 'cross_ip_browser:' . $ip . '|' . $browser_id; + } + return $cross; + } + + /** + * Every spam row to clear after puzzle success (canonical + legacy raw-phone keys). + * + * @param string $email Email. + * @param string $phone Phone. + * @param string $ip IP. + * @param string $browser_id Browser id. + * @return string[] + */ + private function mosp_get_identifiers_to_reset_on_puzzle_success( $email, $phone, $ip, $browser_id ) { + $out = array_merge( + $this->mosp_get_all_identifiers( $email, $phone, $ip, $browser_id ), + $this->mosp_build_cross_identifier_strings( $email, $phone, $ip, $browser_id ) + ); + + $norm_phone = $this->mosp_normalize_phone_for_spam( $phone ); + $raw_phone = trim( (string) $phone ); + if ( '' !== $raw_phone ) { + $legacy_vals = array_unique( + array_filter( + array( + $raw_phone, + preg_replace( '/[^0-9+]/', '', $raw_phone ), + ) + ) + ); + foreach ( $legacy_vals as $lp ) { + if ( '' === $lp || $lp === $norm_phone ) { + continue; + } + $out[] = 'phone:' . $lp; + if ( ! empty( $ip ) ) { + $out[] = 'cross_ip_phone:' . $ip . '|' . $lp; + } + } + } + + return array_values( array_unique( array_filter( $out ) ) ); + } + + /** + * Check cross-identifier blocking (IP + OTP type combination). + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param string $ip IP address. + * @param string $browser_id Browser fingerprint ID. + * @return bool True if should be blocked + */ + private function mosp_is_cross_identifier_blocked( $email, $phone, $ip, $browser_id ) { + foreach ( $this->mosp_build_cross_identifier_strings( $email, $phone, $ip, $browser_id ) as $cross_identifier ) { + if ( $this->is_identifier_blocked( $cross_identifier ) ) { + return true; + } + } + return false; + } + + /** + * Record cross-identifier attempt. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param string $ip IP address. + * @param string $browser_id Browser fingerprint ID. + * @param int $current_time Current timestamp. + * @param array $context Context array. + * @return void + */ + private function mosp_record_cross_identifier_attempt( $email, $phone, $ip, $browser_id, $current_time, $context = array() ) { + foreach ( $this->mosp_build_cross_identifier_strings( $email, $phone, $ip, $browser_id ) as $cross_identifier ) { + $this->mosp_record_identifier_attempt( $cross_identifier, $current_time, $context ); + } + } + + /** + * Get block data for identifiers. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param string $browser_id Browser fingerprint ID. + * @param string $context Context of the check ('otp_send' or 'timer_status'). + * @return array Block data with remaining time + */ + public function mosp_get_block_data( $email, $phone, $browser_id = '', $context = 'otp_send' ) { + if ( ! $this->mosp_is_addon_enabled() ) { + return array( + 'remaining_time' => 0, + 'reason' => '', + ); + } + + $current_time = time(); + $settings = $this->storage->mosp_get_settings(); + + $ip = $this->mosp_get_client_ip(); + + if ( ! empty( $ip ) ) { + $is_whitelisted = $this->storage->mosp_is_whitelisted( $ip, 'ip' ); + if ( $is_whitelisted ) { + return array( + 'remaining_time' => 0, + 'reason' => '', + ); + } + } + + $max_remaining_time = 0; + $block_reason = ''; + + if ( $this->mosp_is_daily_limit_exceeded( $email, $phone, $settings, $context ) ) { + $max_remaining_time = $this->mosp_get_daily_limit_reset_time( $email, $phone ); + $block_reason = 'daily_limit_exceeded'; + } elseif ( $this->mosp_is_hourly_limit_exceeded( $email, $phone, $settings, $context ) ) { + $max_remaining_time = $this->mosp_get_hourly_limit_reset_time( $email, $phone ); + $block_reason = 'hourly_limit_exceeded'; + } + + if ( $max_remaining_time <= 0 ) { + $identifiers = $this->mosp_get_all_identifiers( $email, $phone, $ip, $browser_id ); + + foreach ( $identifiers as $identifier ) { + $block_data = $this->storage->mosp_is_blocked( $identifier ); + + if ( $block_data['blocked'] ) { + $blocked_until = $block_data['blocked_until']; + $remaining = $blocked_until - $current_time; + if ( $remaining > $max_remaining_time ) { + $max_remaining_time = $remaining; + $block_reason = $block_data['reason']; + } + } + } + } + + $final_remaining = max( 0, $max_remaining_time ); + return array( + 'remaining_time' => $final_remaining, + 'reason' => $block_reason, + ); + } + + /** + * Get block message with timer. + * + * @param int $remaining_time Remaining block time in seconds. + * @param string $message_type Optional message type constant (defaults to USER_IS_BLOCKED_AJAX). + * @return string Block message with timer placeholder + */ + public function mosp_get_block_message_with_timer( $remaining_time, $message_type = null ) { + $minutes = floor( $remaining_time / 60 ); + $seconds = $remaining_time % 60; + + $formatted_minutes = sprintf( '%02d', $minutes ); + $formatted_seconds = sprintf( '%02d', $seconds ); + + $message_constant = $message_type ? $message_type : MoMessages::USER_IS_BLOCKED_AJAX; + $message_template = MoMessages::showMessage( $message_constant ); + + $message = $message_template; + + if ( strpos( $message, '{minutes}' ) !== false || strpos( $message, '{seconds}' ) !== false ) { + $message = str_replace( + array( '{minutes}', '{seconds}' ), + array( $formatted_minutes, $formatted_seconds ), + $message + ); + } + + if ( strpos( $message, '{{remaining_time}}' ) !== false ) { + $time_display = sprintf( '%02d:%02d', $minutes, $seconds ); + $message = str_replace( '{{remaining_time}}', $time_display, $message ); + } + + if ( strpos( $message, '%' ) !== false ) { + $time_display = sprintf( '%02d:%02d', $minutes, $seconds ); + $message = sprintf( $message, $time_display ); + } + + return $message; + } + + /** + * Check if user would be blocked after recording one more attempt. + * This prevents race condition where OTP is sent successfully but user gets blocked immediately after + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param string $browser_id Browser fingerprint ID. + * @return bool True if would be blocked after attempt, false otherwise + */ + public function mosp_would_be_blocked_after_attempt( $email, $phone, $browser_id = '' ) { + $result = $this->mosp_would_be_blocked_after_attempt_with_details( $email, $phone, $browser_id ); + return $result['would_be_blocked']; + } + + /** + * Check if user would be blocked after recording one more attempt, with detailed information. + * This prevents race condition where OTP is sent successfully but user gets blocked immediately after. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param string $browser_id Browser fingerprint ID. + * @return array Array with 'would_be_blocked' (bool), 'reason' (string), and 'remaining_time' (int) + */ + public function mosp_would_be_blocked_after_attempt_with_details( $email, $phone, $browser_id = '' ) { + if ( ! $this->mosp_is_addon_enabled() ) { + return array( + 'would_be_blocked' => false, + 'reason' => '', + 'remaining_time' => 0, + ); + } + + $settings = $this->storage->mosp_get_settings(); + $ip = $this->mosp_get_client_ip(); + $identifiers = $this->mosp_get_all_identifiers( $email, $phone, $ip, $browser_id ); + + foreach ( $identifiers as $identifier ) { + $block_data = $this->storage->mosp_is_blocked( $identifier ); + if ( $block_data['blocked'] && 'cooldown' === $block_data['reason'] ) { + $now = time(); + $remaining_time = $block_data['blocked_until'] - $now; + if ( $remaining_time > 0 ) { + return array( + 'would_be_blocked' => true, + 'reason' => 'cooldown', + 'remaining_time' => $remaining_time, + ); + } + } + } + + foreach ( $identifiers as $identifier ) { + $cooldown_result = $this->mosp_would_be_on_cooldown_after_attempt_with_details( $identifier, $settings ); + if ( $cooldown_result['would_be_on_cooldown'] ) { + return array( + 'would_be_blocked' => true, + 'reason' => 'cooldown', + 'remaining_time' => $cooldown_result['remaining_time'], + ); + } + } + + if ( $this->mosp_would_exceed_hourly_limit_after_attempt( $email, $phone, $settings ) ) { + $remaining_time = $this->mosp_get_hourly_limit_reset_time( $email, $phone ); + return array( + 'would_be_blocked' => true, + 'reason' => 'hourly_limit_exceeded', + 'remaining_time' => $remaining_time, + ); + } + + if ( $this->mosp_would_exceed_daily_limit_after_attempt( $email, $phone, $settings ) ) { + $remaining_time = $this->mosp_get_daily_limit_reset_time( $email, $phone ); + return array( + 'would_be_blocked' => true, + 'reason' => 'daily_limit_exceeded', + 'remaining_time' => $remaining_time, + ); + } + + foreach ( $identifiers as $identifier ) { + $max_attempts_result = $this->mosp_would_exceed_max_attempts_after_attempt_with_details( $identifier, $settings ); + if ( $max_attempts_result['would_exceed'] ) { + return array( + 'would_be_blocked' => true, + 'reason' => 'max_attempts_exceeded', + 'remaining_time' => $max_attempts_result['remaining_time'], + ); + } + } + + return array( + 'would_be_blocked' => false, + 'reason' => '', + 'remaining_time' => 0, + ); + } + + /** + * Check if recording one more attempt would exceed hourly limit. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param array $settings Plugin settings. + * @return bool True if would exceed hourly limit after attempt, false otherwise + */ + private function mosp_would_exceed_hourly_limit_after_attempt( $email, $phone, $settings ) { + $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); + if ( empty( $identifiers ) ) { + return false; + } + + foreach ( $identifiers as $identifier ) { + if ( $this->mosp_has_completed_limit_puzzle_for_identifier( $identifier ) ) { + continue; + } + $current_attempts = MoRateLimitHelper::mosp_get_hourly_attempts( $identifier ); + if ( ( $current_attempts + 1 ) > $settings['hourly_limit'] ) { + return true; + } + } + + return false; + } + + /** + * Check if recording one more attempt would exceed daily limit. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param array $settings Plugin settings. + * @return bool True if would exceed daily limit after attempt, false otherwise + */ + private function mosp_would_exceed_daily_limit_after_attempt( $email, $phone, $settings ) { + $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); + if ( empty( $identifiers ) ) { + return false; + } + + foreach ( $identifiers as $identifier ) { + if ( $this->mosp_has_completed_limit_puzzle_for_identifier( $identifier ) ) { + continue; + } + $current_attempts = MoRateLimitHelper::mosp_get_daily_attempts( $identifier ); + if ( ( $current_attempts + 1 ) > $settings['daily_limit'] ) { + return true; + } + } + + return false; + } + + /** + * Check if recording one more attempt would exceed max attempts for identifier. + * + * @param string $identifier The identifier to check. + * @param array $settings Plugin settings. + * @return bool True if would exceed max attempts after attempt, false otherwise + */ + private function mosp_would_exceed_max_attempts_after_attempt( $identifier, $settings ) { + $result = $this->mosp_would_exceed_max_attempts_after_attempt_with_details( $identifier, $settings ); + return $result['would_exceed']; + } + + /** + * Check if recording one more attempt would exceed max attempts, with detailed information. + * + * @param string $identifier The identifier to check. + * @param array $settings Plugin settings. + * @return array Array with 'would_exceed' (bool) and 'remaining_time' (int) + */ + private function mosp_would_exceed_max_attempts_after_attempt_with_details( $identifier, $settings ) { + $key = $this->storage->mosp_hash_key( $identifier ); + $data = $this->storage->mosp_get_spam_data( $key ); + $now = time(); + + if ( false === $data || ! isset( $data['attempts'] ) || ! is_array( $data['attempts'] ) || empty( $data['attempts'] ) ) { + return array( + 'would_exceed' => false, + 'remaining_time' => 0, + ); + } + + if ( isset( $data['blocked_until'] ) && $data['blocked_until'] > $now ) { + $block_reason = isset( $data['block_reason'] ) ? $data['block_reason'] : ''; + if ( 'max_attempts_exceeded' === $block_reason ) { + $remaining_time = $data['blocked_until'] - $now; + return array( + 'would_exceed' => true, + 'remaining_time' => $remaining_time, + ); + } + } + + $time_window = MoSecurityHelper::COUNTING_WINDOW_SECONDS; // 15 minutes. + $cutoff_time = $now - $time_window; + $recent_attempts = array(); + + foreach ( $data['attempts'] as $timestamp ) { + if ( $timestamp > $cutoff_time ) { + $recent_attempts[] = $timestamp; + } + } + + $recent_attempts_count = count( $recent_attempts ); + + if ( ( $recent_attempts_count + 1 ) > $settings['max_attempts'] ) { + if ( isset( $data['blocked_until'] ) && $data['blocked_until'] > $now ) { + $block_reason = isset( $data['block_reason'] ) ? $data['block_reason'] : ''; + if ( 'max_attempts_exceeded' === $block_reason ) { + $remaining_time = $data['blocked_until'] - $now; + return array( + 'would_exceed' => true, + 'remaining_time' => $remaining_time, + ); + } + } + + $block_time_seconds = $settings['block_time']; + $remaining_time = $block_time_seconds; + + return array( + 'would_exceed' => true, + 'remaining_time' => $remaining_time, + ); + } + + return array( + 'would_exceed' => false, + 'remaining_time' => 0, + ); + } + + /** + * Check if recording one more attempt would put the identifier on cooldown. + * This checks if there's a recent attempt that would trigger cooldown after adding this attempt. + * + * @param string $identifier The identifier to check. + * @param array $settings Plugin settings. + * @return bool True if would be on cooldown after attempt, false otherwise + */ + private function mosp_would_be_on_cooldown_after_attempt( $identifier, $settings ) { + $result = $this->mosp_would_be_on_cooldown_after_attempt_with_details( $identifier, $settings ); + return $result['would_be_on_cooldown']; + } + + /** + * Check if recording one more attempt would put the identifier on cooldown, with detailed information. + * This checks if there's a recent attempt that would trigger cooldown after adding this attempt. + * + * @param string $identifier The identifier to check. + * @param array $settings Plugin settings. + * @return array Array with 'would_be_on_cooldown' (bool) and 'remaining_time' (int) + */ + private function mosp_would_be_on_cooldown_after_attempt_with_details( $identifier, $settings ) { + $key = $this->storage->mosp_hash_key( $identifier ); + $data = $this->storage->mosp_get_spam_data( $key ); + $now = time(); + + if ( false === $data || ! isset( $data['attempts'] ) || ! is_array( $data['attempts'] ) || empty( $data['attempts'] ) ) { + return array( + 'would_be_on_cooldown' => false, + 'remaining_time' => 0, + ); + } + + $cooldown_time = $settings['cooldown_time']; + $attempts = $data['attempts']; + $attempt_count = count( $attempts ); + + if ( 1 === $attempt_count ) { + $most_recent_attempt = max( $attempts ); + $time_since_most_recent = $now - $most_recent_attempt; + + if ( $time_since_most_recent < $cooldown_time ) { + $remaining_cooldown = $cooldown_time - $time_since_most_recent; + return array( + 'would_be_on_cooldown' => true, + 'remaining_time' => $remaining_cooldown, + ); + } + + return array( + 'would_be_on_cooldown' => false, + 'remaining_time' => 0, + ); + } + $most_recent_attempt = max( $attempts ); + + $time_since_most_recent = $now - $most_recent_attempt; + + if ( $time_since_most_recent < $cooldown_time ) { + $remaining_cooldown = $cooldown_time - $time_since_most_recent; + if ( $remaining_cooldown < 0 ) { + $remaining_cooldown = 0; + } + return array( + 'would_be_on_cooldown' => true, + 'remaining_time' => $remaining_cooldown, + ); + } + + return array( + 'would_be_on_cooldown' => false, + 'remaining_time' => 0, + ); + } + + /** + * Check if a specific identifier is blocked. + * + * @param string $identifier The identifier to check. + * @return bool True if blocked, false otherwise + */ + private function is_identifier_blocked( $identifier ) { + // Use the storage method that contains the complete blocking logic. + $block_data = $this->storage->mosp_is_blocked( $identifier ); + return $block_data['blocked']; + } + + /** + * Record an attempt for a specific identifier. + * + * @param string $identifier The identifier. + * @param int $current_time Current timestamp. + * @param array $context Context array. + */ + private function mosp_record_identifier_attempt( $identifier, $current_time, $context = array() ) { + $this->storage->mosp_record_attempt_with_timestamp( $identifier, $current_time, $context ); + } + + /** + * Check for spam before OTP is sent. + * + * @param bool $allow Whether to allow OTP sending. + * @param string $user_login Username. + * @param string $user_email Email address. + * @param string $phone_number Phone number. + * @return bool|WP_Error + */ + public function mosp_check_spam_before_otp_send( $allow, $user_login, $user_email, $phone_number ) { + if ( ! $this->mosp_is_addon_enabled() ) { + return $allow; + } + + $settings = $this->storage->mosp_get_settings(); + + if ( ! empty( $user_email ) ) { + MoPHPSessions::add_session_var( 'user_email', $user_email ); + } + if ( ! empty( $phone_number ) ) { + MoPHPSessions::add_session_var( 'phone_number_mo', $phone_number ); + } + + $identifiers = $this->mosp_get_request_identifiers( $user_email, $phone_number ); + + foreach ( $identifiers as $type => $identifier ) { + if ( empty( $identifier ) ) { + continue; + } + + if ( $this->storage->mosp_is_whitelisted( $identifier, $type ) ) { + continue; + } + + $block_status = $this->storage->mosp_is_blocked( $identifier ); + + if ( $block_status['blocked'] ) { + return $this->create_block_error( $block_status, $type, $identifier ); + } + } + + return $allow; + } + + /** + * Record OTP attempt after successful send + * + * @param string $user_login Username. + * @param string $user_email Email address. + * @param string $phone_number Phone number. + * @return void + */ + public function mosp_record_otp_attempt( $user_login, $user_email, $phone_number ) { + if ( ! $this->mosp_is_addon_enabled() ) { + return; + } + + $settings = $this->storage->mosp_get_settings(); + $ip = $this->mosp_get_client_ip(); + $browser = $this->get_browser_id(); + + $identifiers = $this->mosp_get_request_identifiers( $user_email, $phone_number ); + $context = array( + 'ip' => $ip, + 'browser_id' => $browser, + 'email' => isset( $identifiers['email'] ) ? $identifiers['email'] : '', + 'phone' => isset( $identifiers['phone'] ) ? $identifiers['phone'] : '', + ); + + foreach ( $identifiers as $type => $identifier ) { + if ( empty( $identifier ) || $this->storage->mosp_is_whitelisted( $identifier, $type ) ) { + continue; + } + + $this->storage->mosp_record_attempt( $identifier, $type, $context ); + } + } + + /** + * Get all identifiers for the current request. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @return array Array of identifiers. + */ + private function mosp_get_request_identifiers( $email, $phone ) { + $settings = $this->storage->mosp_get_settings(); + $identifiers = array(); + + if ( $settings['track_email'] && ! empty( $email ) ) { + $identifiers['email'] = strtolower( trim( $email ) ); + } + + if ( $settings['track_phone'] && ! empty( $phone ) ) { + $identifiers['phone'] = preg_replace( '/[^0-9+]/', '', $phone ); + } + + if ( $settings['track_ip'] ) { + $ip = $this->mosp_get_client_ip(); + if ( $ip ) { + $identifiers['ip'] = $ip; + } + } + + if ( $settings['track_browser'] ) { + $browser_id = $this->get_browser_id(); + if ( $browser_id ) { + $identifiers['browser'] = $browser_id; + } + } + + return $identifiers; + } + + /** + * Get client IP address with anti-spoofing protection + * + * @return string + */ + public function mosp_get_client_ip() { + $ip_candidates = $this->get_ip_candidates(); + + if ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) { + $remote_addr = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ); + if ( filter_var( $remote_addr, FILTER_VALIDATE_IP ) && + ! filter_var( $remote_addr, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { + if ( $this->storage->mosp_is_whitelisted( $remote_addr, 'ip' ) ) { + return $remote_addr; + } + } + } + + $validated_ip = $this->validate_ip_security( $ip_candidates ); + + return $validated_ip; + } + + /** + * Get all possible IP addresses from headers. + * + * @return array Array of IP candidates with their sources + */ + private function get_ip_candidates() { + $candidates = array(); + + $ip_sources = array( + 'REMOTE_ADDR' => array( + 'priority' => 1, + 'spoofable' => false, + ), + 'HTTP_CLIENT_IP' => array( + 'priority' => 2, + 'spoofable' => true, + ), + 'HTTP_CF_CONNECTING_IP' => array( + 'priority' => 3, + 'spoofable' => false, + ), + 'HTTP_X_REAL_IP' => array( + 'priority' => 4, + 'spoofable' => true, + ), + 'HTTP_X_FORWARDED_FOR' => array( + 'priority' => 5, + 'spoofable' => true, + ), + 'HTTP_X_FORWARDED' => array( + 'priority' => 6, + 'spoofable' => true, + ), + 'HTTP_X_CLUSTER_CLIENT_IP' => array( + 'priority' => 7, + 'spoofable' => true, + ), + 'HTTP_FORWARDED_FOR' => array( + 'priority' => 8, + 'spoofable' => true, + ), + 'HTTP_FORWARDED' => array( + 'priority' => 9, + 'spoofable' => true, + ), + ); + + foreach ( $ip_sources as $header => $config ) { + if ( ! empty( $_SERVER[ $header ] ) ) { + $raw_value = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ); + $ips = $this->parse_ip_header( $raw_value ); + + foreach ( $ips as $ip ) { + if ( $this->is_valid_public_ip( $ip ) ) { + $candidates[] = array( + 'ip' => $ip, + 'source' => $header, + 'priority' => $config['priority'], + 'spoofable' => $config['spoofable'], + 'raw_header' => $raw_value, + ); + } + } + } + } + + return $candidates; + } + + /** + * Parse IP header value (handles comma-separated lists). + * + * @param string $header_value Raw header value. + * @return array Array of IP addresses. + */ + private function parse_ip_header( $header_value ) { + $ips = array(); + + if ( strpos( $header_value, ',' ) !== false ) { + $parts = explode( ',', $header_value ); + foreach ( $parts as $part ) { + $ip = trim( $part ); + if ( ! empty( $ip ) ) { + $ips[] = $ip; + } + } + } else { + $ips[] = trim( $header_value ); + } + + return $ips; + } + + /** + * Validate IP with security checks. + * + * @param string $ip IP address to validate. + * @return bool True if valid public IP. + */ + private function is_valid_public_ip( $ip ) { + if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { + return false; + } + + if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { + return false; + } + + if ( $this->is_suspicious_ip( $ip ) ) { + return false; + } + + return true; + } + + /** + * Check if IP appears suspicious. + * + * @param string $ip IP address. + * @return bool True if suspicious. + */ + private function is_suspicious_ip( $ip ) { + $suspicious_patterns = array( + '0.0.0.0', + '255.255.255.255', + '1.1.1.1', + '8.8.8.8', + '127.0.0.1', + '169.254.0.0', + '224.0.0.0', + '240.0.0.0', + ); + + foreach ( $suspicious_patterns as $pattern ) { + if ( strpos( $ip, $pattern ) === 0 ) { + return true; + } + } + + return false; + } + + /** + * Validate IP security and select most trustworthy. + * + * @param array $candidates Array of IP candidates. + * @return string Most trustworthy IP address. + */ + private function validate_ip_security( $candidates ) { + if ( empty( $candidates ) ) { + return ''; + } + + usort( + $candidates, + function ( $a, $b ) { + return $a['priority'] - $b['priority']; + } + ); + + $remote_addr = $this->get_remote_addr_ip( $candidates ); + $proxy_detection = $this->detect_proxy_environment(); + + if ( ! $proxy_detection['behind_proxy'] ) { + return $remote_addr ? $remote_addr : ''; + } + + if ( $proxy_detection['trusted_proxy'] ) { + foreach ( $candidates as $candidate ) { + if ( 'HTTP_CF_CONNECTING_IP' === $candidate['source'] && ! $candidate['spoofable'] ) { + return $candidate['ip']; + } + } + foreach ( $candidates as $candidate ) { + if ( ! $candidate['spoofable'] ) { + return $candidate['ip']; + } + } + } + + return $remote_addr ? $remote_addr : $candidates[0]['ip']; + } + + /** + * Get REMOTE_ADDR IP from candidates. + * + * @param array $candidates IP candidates. + * @return string|null REMOTE_ADDR IP or null. + */ + private function get_remote_addr_ip( $candidates ) { + foreach ( $candidates as $candidate ) { + if ( 'REMOTE_ADDR' === $candidate['source'] ) { + return $candidate['ip']; + } + } + return null; + } + + /** + * Detect proxy environment. + * + * @return array Proxy detection results. + */ + private function detect_proxy_environment() { + + $result = array( + 'behind_proxy' => false, + 'trusted_proxy' => false, + 'proxy_type' => 'none', + ); + + if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) || ! empty( $_SERVER['HTTP_CF_RAY'] ) ) { + $result['behind_proxy'] = true; + $result['trusted_proxy'] = true; + $result['proxy_type'] = 'cloudflare'; + return $result; + } + + $trusted_headers = array( + 'HTTP_CLIENT_IP', + 'HTTP_X_FORWARDED_FOR', + 'HTTP_X_REAL_IP', + ); + + foreach ( $trusted_headers as $header ) { + if ( ! empty( $_SERVER[ $header ] ) ) { + $result['behind_proxy'] = true; + $remote_addr = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; + if ( $this->is_known_proxy_ip( $remote_addr ) ) { + $result['trusted_proxy'] = true; + } + $result['proxy_type'] = 'generic'; + break; + } + } + + return $result; + } + + /** + * Check if IP belongs to known proxy services. + * + * @param string $ip IP address to check. + * @return bool True if known proxy IP + */ + private function is_known_proxy_ip( $ip ) { + if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { + return false; + } + + // Cloudflare IP ranges (simplified check). + $cloudflare_ranges = array( + '173.245.48.0/20', + '103.21.244.0/22', + '103.22.200.0/22', + '103.31.4.0/22', + '141.101.64.0/18', + '108.162.192.0/18', + '190.93.240.0/20', + '188.114.96.0/20', + '197.234.240.0/22', + '198.41.128.0/17', + '162.158.0.0/15', + '104.16.0.0/13', + '104.24.0.0/14', + '172.64.0.0/13', + '131.0.72.0/22', + ); + + foreach ( $cloudflare_ranges as $range ) { + if ( $this->ip_in_range( $ip, $range ) ) { + return true; + } + } + + return false; + } + + /** + * Check if IP is in CIDR range. + * + * @param string $ip IP to check. + * @param string $range CIDR range. + * @return bool True if IP is in range + */ + private function ip_in_range( $ip, $range ) { + if ( strpos( $range, '/' ) === false ) { + return $ip === $range; + } + + list($subnet, $bits) = explode( '/', $range ); + + if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) && filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { + $ip_long = ip2long( $ip ); + $subnet_long = ip2long( $subnet ); + $mask = -1 << ( 32 - (int) $bits ); + $subnet_long &= $mask; + return ( $ip_long & $mask ) === $subnet_long; + } + + return false; + } + + /** + * Detect IP switching attacks. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param string $browser_id Browser ID. + * @param string $current_ip Current IP address. + * @return bool True if attack detected + */ + private function detect_ip_switching_attack( $email, $phone, $browser_id, $current_ip ) { + if ( empty( $current_ip ) ) { + return false; + } + + $tracking_key = ''; + if ( ! empty( $email ) ) { + $tracking_key = 'email:' . $email; + } elseif ( ! empty( $phone ) ) { + $tracking_key = 'phone:' . $phone; + } elseif ( ! empty( $browser_id ) ) { + $tracking_key = 'browser:' . $browser_id; + } + + if ( empty( $tracking_key ) ) { + return false; + } + + $ip_history_key = 'mo_osp_ip_history_' . md5( $tracking_key ); + $ip_history = MoPHPSessions::get_session_var( $ip_history_key ); + + if ( false === $ip_history ) { + $ip_history = array(); + } + + $current_time = time(); + $ip_history[] = array( + 'ip' => $current_ip, + 'timestamp' => $current_time, + ); + + $cutoff_time = $current_time - 600; + $ip_history = array_filter( + $ip_history, + function ( $entry ) use ( $cutoff_time ) { + return $entry['timestamp'] > $cutoff_time; + } + ); + + $unique_ips = array(); + foreach ( $ip_history as $entry ) { + $unique_ips[ $entry['ip'] ] = true; + } + + MoPHPSessions::add_session_var( $ip_history_key, $ip_history ); // 10 minutes + + return count( $unique_ips ) > 3; + } + + /** + * Log security events. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param string $ip IP address. + * @param string $browser_id Browser ID. + * @param string $event_type Event type. + * @return void + */ + private function mosp_log_security_event( $email, $phone, $ip, $browser_id, $event_type = 'OTP_REQUEST' ) { + if ( 'OTP_REQUEST' === $event_type ) { + return; + } + + $log_entry = array( + 'timestamp' => current_time( 'mysql' ), + 'event_type' => $event_type, + 'email' => $email ? wp_hash( $email ) : '', + 'phone' => $phone ? wp_hash( $phone ) : '', + 'ip' => $ip ? wp_hash( $ip ) : '', + 'browser_id' => $browser_id, + 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', //phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized within the function. + 'referer' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '', //phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- esc_url_raw() handles sanitization. + ); + + $log_key = 'mo_osp_security_log'; + $existing_log = get_mo_option( $log_key ); + + if ( is_string( $existing_log ) ) { + $maybe = maybe_unserialize( $existing_log ); + $existing_log = is_array( $maybe ) ? $maybe : array(); + } elseif ( ! is_array( $existing_log ) ) { + $existing_log = array(); + } + + if ( count( $existing_log ) >= 100 ) { + $existing_log = array_slice( $existing_log, -99 ); + } + + $existing_log[] = $log_entry; + update_mo_option( $log_key, $existing_log ); + } + + /** + * Get browser identifier from request. + * + * @return string + */ + private function get_browser_id() { + // phpcs:disable WordPress.Security.NonceVerification.Missing -- Called from OTP generation hook, no nonce available + if ( isset( $_POST['mo_osp_browser_id'] ) ) { //phpcs:ignore WordPress.Security.NonceVerification.Missing -- Called from OTP generation hook, no nonce available + return sanitize_text_field( wp_unslash( $_POST['mo_osp_browser_id'] ) ); //phpcs:ignore WordPress.Security.NonceVerification.Missing -- Sanitized within the function. + } + + $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; //phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized within the function. + if ( $user_agent ) { + return hash( 'sha256', $user_agent ); + } + + return ''; + } + + /** + * Create error for blocked request. + * + * @param array $block_status Block status information. + * @param string $type Identifier type. + * @param string $identifier The identifier. + * @return WP_Error. + */ + private function create_block_error( $block_status, $type, $identifier ) { + $masked_id = $this->storage->mosp_mask_identifier( $identifier, $type ); + + switch ( $block_status['reason'] ) { + case 'cooldown': + $message = sprintf( + /* translators: %1$s: masked identifier, %2$d: remaining seconds */ + __( 'Please wait %2$d seconds before requesting another OTP for %1$s.', 'miniorange-otp-verification' ), + $masked_id, + $block_status['remaining'] + ); + break; + + case 'max_attempts_exceeded': + $blocked_until = date_i18n( get_mo_option( 'time_format' ), $block_status['blocked_until'] ); + $message = sprintf( + /* translators: %1$s: masked identifier, %2$s: time when block expires */ + MoMessages::showMessage( MoMessages::USER_IS_BLOCKED_AJAX ), + $masked_id, + $blocked_until + ); + break; + + case 'temporarily_blocked': + $blocked_until = date_i18n( get_mo_option( 'time_format' ), $block_status['blocked_until'] ); + $message = sprintf( + /* translators: %1$s: masked identifier, %2$s: time when block expires */ + __( 'Access temporarily blocked for %1$s. Please try again after %2$s.', 'miniorange-otp-verification' ), + $masked_id, + $blocked_until + ); + break; + + default: + $message = __( 'OTP request blocked due to spam prevention measures.', 'miniorange-otp-verification' ); + } + + return new \WP_Error( 'otp_spam_blocked', $message ); + } + + /** + * Check if user requires puzzle verification. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param string $ip IP address. + * @param string $browser_id Browser fingerprint. + * @return bool. + */ + public function mosp_requires_puzzle_verification( $email, $phone, $ip, $browser_id ) { + if ( ! $this->mosp_is_addon_enabled() ) { + return false; + } + + return $this->storage->mosp_is_puzzle_required_for_user( $email, $phone, $ip, $browser_id ); + } + + /** + * Clear puzzle requirement after successful verification. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param string $ip IP address. + * @param string $browser_id Browser fingerprint. + * @return void. + */ + public function mosp_clear_puzzle_requirements( $email, $phone, $ip, $browser_id ) { + $to_clear = array(); + + $raw_email = trim( (string) $email ); + $norm_email = $this->mosp_normalize_email_for_spam( $email ); + if ( '' !== $raw_email ) { + $to_clear[] = $raw_email; + } + if ( '' !== $norm_email && $norm_email !== $raw_email ) { + $to_clear[] = $norm_email; + } + + $raw_phone = trim( (string) $phone ); + $norm_phone = $this->mosp_normalize_phone_for_spam( $phone ); + if ( '' !== $raw_phone ) { + $to_clear[] = $raw_phone; + } + if ( '' !== $norm_phone && $norm_phone !== $raw_phone ) { + $to_clear[] = $norm_phone; + } + + if ( ! empty( $ip ) ) { + $to_clear[] = $ip; + } + if ( ! empty( $browser_id ) ) { + $to_clear[] = $browser_id; + } + + foreach ( array_unique( array_filter( $to_clear ) ) as $identifier ) { + $this->storage->mosp_clear_puzzle_requirement( $identifier ); + } + } + + /** + * Check if user has completed puzzle verification for hourly/daily limits. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @return bool True if puzzle was completed + */ + public function mosp_has_completed_limit_puzzle( $email, $phone ) { + if ( ! $this->mosp_is_addon_enabled() ) { + return false; + } + + $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); + if ( empty( $identifiers ) ) { + return false; + } + + foreach ( $identifiers as $identifier ) { + if ( ! $this->mosp_has_completed_limit_puzzle_for_identifier( $identifier ) ) { + return false; + } + } + + return true; + } + + /** + * Mark that user has completed puzzle verification for hourly/daily limits. + * + * @param string $email Email address. + * @param string $phone Phone number. + */ + public function mosp_mark_limit_puzzle_completed( $email, $phone ) { + $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); + if ( empty( $identifiers ) ) { + return; + } + + foreach ( $identifiers as $identifier ) { + $puzzle_key = 'limit_puzzle_' . $this->storage->mosp_hash_key( $identifier ); + MoPHPSessions::add_session_var( $puzzle_key, 'completed' ); + + $permanent_key = 'puzzle_ever_completed_' . $this->storage->mosp_hash_key( $identifier ); + update_option( $permanent_key, time() ); + + MoRateLimitHelper::mosp_clear_rate_limit( $identifier, 'hourly' ); + MoRateLimitHelper::mosp_clear_rate_limit( $identifier, 'daily' ); + } + + $this->mosp_reset_immediate_spam_protection( $email, $phone ); + } + + /** + * Reset immediate spam protection after puzzle completion. + * + * This clears cooldown timers, attempt counts in the 15-minute window, and blocks. + * Note: Daily/hourly rate limits are cleared separately in mosp_mark_limit_puzzle_completed(). + * + * @param string $email Email address. + * @param string $phone Phone number. + */ + public function mosp_reset_immediate_spam_protection( $email, $phone ) { + $ip = $this->mosp_get_client_ip(); + $browser_id = $this->get_browser_id(); + + $identifiers = $this->mosp_get_identifiers_to_reset_on_puzzle_success( $email, $phone, $ip, $browser_id ); + + foreach ( $identifiers as $identifier ) { + $key = $this->storage->mosp_hash_key( $identifier ); + $data = $this->storage->mosp_get_spam_data( $key ); + + if ( false !== $data ) { + $data['attempts'] = array(); + $data['blocked_until'] = 0; + $data['last_attempt'] = 0; + if ( isset( $data['block_count'] ) ) { + $data['block_count'] = 0; + } + if ( isset( $data['block_reason'] ) ) { + $data['block_reason'] = ''; + } + + $this->storage->mosp_update_spam_data( $key, $data ); + } + } + + $this->mosp_clear_puzzle_requirements( $email, $phone, $ip, $browser_id ); + } + + /** + * Check if user requires puzzle verification for hourly/daily limits. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @return bool True if puzzle is required + */ + public function mosp_requires_limit_puzzle_verification( $email, $phone ) { + if ( ! $this->mosp_is_addon_enabled() ) { + return false; + } + + $settings = $this->storage->mosp_get_settings(); + $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); + if ( empty( $identifiers ) ) { + return false; + } + + $ip = $this->mosp_get_client_ip(); + $identifiers = $this->mosp_get_all_identifiers( $email, $phone, $ip, '' ); + + foreach ( $identifiers as $identifier ) { + $block_data = $this->storage->mosp_is_blocked( $identifier ); + if ( $block_data['blocked'] ) { + return false; + } + } + + $daily_exceeded = false; + $hourly_exceeded = false; + $max_attempts_exceeded = false; + + foreach ( $identifiers as $identifier ) { + if ( $this->mosp_has_completed_limit_puzzle_for_identifier( $identifier ) ) { + continue; + } + $daily_attempts = MoRateLimitHelper::mosp_get_daily_attempts( $identifier ); + $hourly_attempts = MoRateLimitHelper::mosp_get_hourly_attempts( $identifier ); + + if ( $daily_attempts >= $settings['daily_limit'] ) { + $daily_exceeded = true; + } + if ( $hourly_attempts >= $settings['hourly_limit'] ) { + $hourly_exceeded = true; + } + if ( $daily_exceeded || $hourly_exceeded ) { + break; + } + } + + $ip = $this->mosp_get_client_ip(); + $all_identifiers = $this->mosp_get_all_identifiers( $email, $phone, $ip, '' ); + + foreach ( $all_identifiers as $identifier ) { + $identifier_data = $this->storage->mosp_get_spam_data( $this->storage->mosp_hash_key( $identifier ) ); + if ( false !== $identifier_data && isset( $identifier_data['attempts'] ) && is_array( $identifier_data['attempts'] ) ) { + $time_window = MoSecurityHelper::COUNTING_WINDOW_SECONDS; // 15 minutes + $cutoff_time = time() - $time_window; + $recent_attempts = 0; + + foreach ( $identifier_data['attempts'] as $timestamp ) { + if ( $timestamp > $cutoff_time ) { + ++$recent_attempts; + } + } + + if ( $recent_attempts > $settings['max_attempts'] ) { + $max_attempts_exceeded = true; + break; + } + } + } + + $requires_puzzle = $daily_exceeded || $hourly_exceeded || $max_attempts_exceeded; + + return $requires_puzzle; + } + + /** + * Check if daily OTP limit is exceeded for a user using sliding window. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param array $settings Settings array. + * @param string $context Context of the check ('otp_send' or 'timer_status'). + * @return bool True if daily limit exceeded + */ + private function mosp_is_daily_limit_exceeded( $email, $phone, $settings, $context = 'otp_send' ) { + $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); + if ( empty( $identifiers ) ) { + return false; + } + + foreach ( $identifiers as $identifier ) { + if ( 'otp_send' === $context && $this->mosp_has_completed_limit_puzzle_for_identifier( $identifier ) ) { + continue; + } + if ( MoRateLimitHelper::mosp_is_daily_limit_exceeded( $identifier, $settings['daily_limit'] ) ) { + return true; + } + } + + return false; + } + + /** + * Get remaining time until daily limit resets (sliding window). + * + * @param string $email Email address. + * @param string $phone Phone number. + * @return int Remaining seconds until oldest attempt expires. + */ + private function mosp_get_daily_limit_reset_time( $email, $phone ) { + $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); + if ( empty( $identifiers ) ) { + return 0; + } + + $max_remaining = 0; + foreach ( $identifiers as $identifier ) { + $remaining = MoRateLimitHelper::mosp_get_reset_time( $identifier, MoRateLimitHelper::DAILY_WINDOW, 'daily' ); + if ( $remaining > $max_remaining ) { + $max_remaining = $remaining; + } + } + + return $max_remaining; + } + + /** + * Get remaining time until hourly limit resets (sliding window). + * + * @param string $email Email address. + * @param string $phone Phone number. + * @return int Remaining seconds until oldest attempt expires. + */ + private function mosp_get_hourly_limit_reset_time( $email, $phone ) { + $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); + if ( empty( $identifiers ) ) { + return 0; + } + + $max_remaining = 0; + foreach ( $identifiers as $identifier ) { + $remaining = MoRateLimitHelper::mosp_get_reset_time( $identifier, MoRateLimitHelper::HOURLY_WINDOW, 'hourly' ); + if ( $remaining > $max_remaining ) { + $max_remaining = $remaining; + } + } + + return $max_remaining; + } + + /** + * Check if hourly OTP limit is exceeded for a user using sliding window. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param array $settings Settings array. + * @param string $context Context of the check ('otp_send' or 'timer_status'). + * @return bool True if hourly limit exceeded + */ + private function mosp_is_hourly_limit_exceeded( $email, $phone, $settings, $context = 'otp_send' ) { + $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); + if ( empty( $identifiers ) ) { + return false; + } + + foreach ( $identifiers as $identifier ) { + if ( 'otp_send' === $context && $this->mosp_has_completed_limit_puzzle_for_identifier( $identifier ) ) { + continue; + } + if ( MoRateLimitHelper::mosp_is_hourly_limit_exceeded( $identifier, $settings['hourly_limit'] ) ) { + return true; + } + } + + return false; + } + + /** + * Get user identifier (email or phone, whichever is available). + * + * @param string $email Email address. + * @param string $phone Phone number. + * @return string User identifier + */ + private function mosp_get_user_identifier( $email, $phone ) { + $np = $this->mosp_normalize_phone_for_spam( $phone ); + if ( '' !== $np ) { + return 'phone:' . $np; + } + $ne = $this->mosp_normalize_email_for_spam( $email ); + if ( '' !== $ne ) { + return 'email:' . $ne; + } + return ''; + } + + /** + * Get identifiers used for hourly/daily limits. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @return array + */ + private function mosp_get_limit_identifiers( $email, $phone ) { + $settings = $this->storage->mosp_get_settings(); + $identifiers = array(); + + if ( $settings['track_phone'] && ! empty( $phone ) ) { + $np = $this->mosp_normalize_phone_for_spam( $phone ); + if ( '' !== $np ) { + $identifiers[] = 'phone:' . $np; + } + return $identifiers; + } + + if ( $settings['track_email'] && ! empty( $email ) ) { + $ne = $this->mosp_normalize_email_for_spam( $email ); + if ( '' !== $ne ) { + $identifiers[] = 'email:' . $ne; + } + } + + return $identifiers; + } + + /** + * Check if user has completed limit puzzle verification for a single identifier. + * + * @param string $identifier Identifier for limit checks. + * @return bool + */ + private function mosp_has_completed_limit_puzzle_for_identifier( $identifier ) { + if ( empty( $identifier ) ) { + return false; + } + + $puzzle_key = 'limit_puzzle_' . $this->storage->mosp_hash_key( $identifier ); + $completed = MoPHPSessions::get_session_var( $puzzle_key ); + + return 'completed' === $completed; + } + + /** + * Record daily and hourly attempts for a user using sliding window. + * + * @param string $email Email address. + * @param string $phone Phone number. + */ + private function mosp_record_daily_hourly_attempts( $email, $phone ) { + $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); + if ( empty( $identifiers ) ) { + return; + } + + foreach ( $identifiers as $identifier ) { + MoRateLimitHelper::mosp_record_attempt_multi_window( $identifier ); + } + } + + /** + * Clear hourly limit for a user (for testing purposes). + * + * Usage: Call this method via WordPress admin or add to functions.php: + * $handler = OSP\Handler\MoOtpSpamPreventerHandler::instance(); + * $handler->mosp_clear_hourly_limit('test@example.com', ''); + * + * Or via database: + * DELETE FROM wp_options WHERE option_name LIKE 'mo_customer_validation_mo_osp_rate_limit_hourly_%'; + * + * @param string $email Email address. + * @param string $phone Phone number. + * @return bool True if cleared successfully. + */ + public function mosp_clear_hourly_limit( $email = '', $phone = '' ) { + $identifiers = $this->mosp_get_limit_identifiers( $email, $phone ); + if ( empty( $identifiers ) ) { + global $wpdb; + $prefix = 'mo_customer_validation_mo_osp_rate_limit_hourly_'; + $cache_key = 'mosp_hourly_limit_options'; + $option_names = wp_cache_get( $cache_key, 'options' ); + + if ( false === $option_names ) { + $option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like( $prefix ) . '%' + ) + ); + wp_cache_set( $cache_key, $option_names, 'options' ); + } + + if ( empty( $option_names ) ) { + return false; + } + + foreach ( $option_names as $option_name ) { + delete_option( $option_name ); + } + + wp_cache_delete( $cache_key, 'options' ); + return true; + } + + $cleared = true; + foreach ( $identifiers as $identifier ) { + if ( ! MoRateLimitHelper::mosp_clear_rate_limit( $identifier, 'hourly' ) ) { + $cleared = false; + } + } + return $cleared; + } + + /** + * Check if addon is enabled. + * + * @return bool + */ + private function mosp_is_addon_enabled() { + $settings = $this->storage->mosp_get_settings(); + return ! empty( $settings['enabled'] ); + } + + /** + * Unblock a user by identifier hash. + * + * @param string $identifier_hash The hashed identifier. + * @return array Result with 'success' and 'message' keys. + */ + public function mosp_unblock_user_by_hash( $identifier_hash ) { + if ( empty( $identifier_hash ) ) { + return array( + 'success' => false, + 'message' => __( 'Invalid identifier hash.', 'miniorange-otp-verification' ), + ); + } + + // The identifier_hash is already the hash, so use it directly. + $key = $identifier_hash; + $data = $this->storage->mosp_get_spam_data( $key ); + $blocked_until = 0; + $block_reason = ''; + + if ( false !== $data ) { + $blocked_until = isset( $data['blocked_until'] ) ? (int) $data['blocked_until'] : 0; + $block_reason = isset( $data['block_reason'] ) ? $data['block_reason'] : ''; + + // Clear block status. + $data['blocked_until'] = 0; + $data['block_reason'] = ''; + $data['attempts'] = array(); + $data['last_attempt'] = 0; + + $this->storage->mosp_update_spam_data( $key, $data ); + + $related_identifiers = $this->mosp_build_related_identifiers( $data ); + $this->mosp_clear_identifiers_data( $related_identifiers ); + } + + // Clear rate limit data for all window types using the helper. + $window_types = array( 'hourly', 'daily' ); + foreach ( $window_types as $window_type ) { + $rate_key = 'rate_limit_' . $window_type . '_' . $identifier_hash; + $this->storage->mosp_delete_spam_data( $rate_key ); + } + + // Clear puzzle requirements. + $this->storage->mosp_clear_puzzle_requirement( $identifier_hash ); + + // Clear cache. + wp_cache_delete( 'mosp_blocked_users_list', 'mo_osp' ); + wp_cache_delete( 'mosp_rate_limit_hourly_options', 'mo_osp' ); + wp_cache_delete( 'mosp_rate_limit_daily_options', 'mo_osp' ); + wp_cache_delete( 'mosp_spam_data_option_names', 'mo_osp' ); + + $this->mosp_clear_related_blocks_by_reason( $blocked_until, $block_reason, $identifier_hash ); + + return array( + 'success' => true, + 'message' => __( 'User unblocked successfully.', 'miniorange-otp-verification' ), + ); + } + + /** + * Clear all blocked-user data, rate limits, and puzzle flags (admin only). + * + * @return array{ success: bool, message: string, deleted: int } + */ + public function mosp_clear_all_blocked_data() { + $deleted = $this->storage->mosp_clear_all_otp_spam_data(); + + if ( 0 === $deleted ) { + return array( + 'success' => false, + 'deleted' => 0, + 'message' => __( 'No entries found to clear.', 'miniorange-otp-verification' ), + ); + } + + return array( + 'success' => true, + 'deleted' => $deleted, + 'message' => sprintf( + /* translators: %d: number of database options removed */ + _n( + 'Cleared %d stored entry (blocks, rate limits, and puzzle flags).', + 'Cleared %d stored entries (blocks, rate limits, and puzzle flags).', + $deleted, + 'miniorange-otp-verification' + ), + $deleted + ), + ); + } + + /** + * Build related identifiers from stored metadata. + * + * @param array $data Spam data. + * @return array + */ + private function mosp_build_related_identifiers( $data ) { + $related_identifiers = array(); + if ( isset( $data['last_ip'] ) && filter_var( $data['last_ip'], FILTER_VALIDATE_IP ) ) { + $related_identifiers[] = $data['last_ip']; + $related_identifiers[] = 'ip:' . $data['last_ip']; + } + if ( isset( $data['last_browser'] ) && ! empty( $data['last_browser'] ) ) { + $related_identifiers[] = $data['last_browser']; + $related_identifiers[] = 'browser:' . $data['last_browser']; + } + if ( isset( $data['last_email'] ) && ! empty( $data['last_email'] ) ) { + $related_identifiers[] = $data['last_email']; + $related_identifiers[] = 'email:' . $data['last_email']; + } + if ( isset( $data['last_phone'] ) && ! empty( $data['last_phone'] ) ) { + $related_identifiers[] = $data['last_phone']; + $related_identifiers[] = 'phone:' . $data['last_phone']; + } + + $last_ip = isset( $data['last_ip'] ) ? $data['last_ip'] : ''; + $last_email = isset( $data['last_email'] ) ? $data['last_email'] : ''; + $last_phone = isset( $data['last_phone'] ) ? $data['last_phone'] : ''; + $last_browser = isset( $data['last_browser'] ) ? $data['last_browser'] : ''; + + if ( $last_ip && $last_email ) { + $related_identifiers[] = 'cross_ip_email:' . $last_ip . '|' . $last_email; + } + if ( $last_ip && $last_phone ) { + $related_identifiers[] = 'cross_ip_phone:' . $last_ip . '|' . $last_phone; + } + if ( $last_ip && $last_browser ) { + $related_identifiers[] = 'cross_ip_browser:' . $last_ip . '|' . $last_browser; + } + + return array_values( array_unique( array_filter( $related_identifiers ) ) ); + } + + /** + * Clear spam + rate-limit data for identifiers. + * + * @param array $identifiers Identifiers to clear. + * @return void + */ + private function mosp_clear_identifiers_data( $identifiers ) { + if ( empty( $identifiers ) || ! is_array( $identifiers ) ) { + return; + } + + foreach ( $identifiers as $identifier ) { + if ( empty( $identifier ) ) { + continue; + } + $hash = $this->storage->mosp_hash_key( $identifier ); + + $identifier_data = $this->storage->mosp_get_spam_data( $hash ); + if ( false !== $identifier_data ) { + $identifier_data['blocked_until'] = 0; + $identifier_data['block_reason'] = ''; + $identifier_data['attempts'] = array(); + $identifier_data['last_attempt'] = 0; + $this->storage->mosp_update_spam_data( $hash, $identifier_data ); + } + + $window_types = array( 'hourly', 'daily' ); + foreach ( $window_types as $window_type ) { + $this->storage->mosp_delete_spam_data( 'rate_limit_' . $window_type . '_' . $hash ); + delete_mo_option( 'mo_osp_rate_limit_' . $window_type . '_' . $hash ); + } + + $this->storage->mosp_clear_puzzle_requirement( $hash ); + } + } + + /** + * Clear blocks that share the same block reason and time. + * + * @param int $blocked_until Blocked until timestamp. + * @param string $block_reason Block reason. + * @param string $exclude_hash Identifier hash to skip. + * @return void + */ + private function mosp_clear_related_blocks_by_reason( $blocked_until, $block_reason, $exclude_hash ) { + if ( empty( $blocked_until ) || empty( $block_reason ) ) { + return; + } + + global $wpdb; + + $option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like( 'mo_customer_validation_' . MoOtpSpamStorage::SPAM_DATA_PREFIX ) . '%' + ) + ); + + if ( empty( $option_names ) ) { + return; + } + + foreach ( $option_names as $db_option_name ) { + $option_key = str_replace( 'mo_customer_validation_', '', $db_option_name ); + $hash_key = str_replace( MoOtpSpamStorage::SPAM_DATA_PREFIX, '', $option_key ); + + if ( $hash_key === $exclude_hash ) { + continue; + } + + $spam_data = $this->storage->mosp_get_spam_data( $hash_key ); + if ( false === $spam_data ) { + continue; + } + + $spam_blocked_until = isset( $spam_data['blocked_until'] ) ? (int) $spam_data['blocked_until'] : 0; + $spam_block_reason = isset( $spam_data['block_reason'] ) ? $spam_data['block_reason'] : ''; + + if ( $spam_blocked_until !== (int) $blocked_until || $spam_block_reason !== $block_reason ) { + continue; + } + + $spam_data['blocked_until'] = 0; + $spam_data['block_reason'] = ''; + $spam_data['attempts'] = array(); + $spam_data['last_attempt'] = 0; + $this->storage->mosp_update_spam_data( $hash_key, $spam_data ); + + $window_types = array( 'hourly', 'daily' ); + foreach ( $window_types as $window_type ) { + $rate_key = 'rate_limit_' . $window_type . '_' . $hash_key; + $this->storage->mosp_delete_spam_data( $rate_key ); + } + + $this->storage->mosp_clear_puzzle_requirement( $hash_key ); + } + } + } +} @@ -1,1624 +1,1624 @@ -<?php -/** - * OTP Spam Storage Handler - * - * @package otpspampreventer/handler - */ - -namespace OSP\Handler; - -use OSP\Traits\Instance; -use OSP\Helper\MoSecurityHelper; - -if ( ! defined( 'ABSPATH' ) ) { - exit; -} - -if ( ! class_exists( 'MoOtpSpamStorage' ) ) { - /** - * The class handles storage and retrieval of spam prevention data. - * Uses WordPress options table to store hashed keys and attempt data. - */ - class MoOtpSpamStorage { - - use Instance; - - /** - * Option name prefix for spam data - */ - const SPAM_DATA_PREFIX = 'mo_osp_spam_data_'; - - /** - * Option name for global settings - */ - const SETTINGS_OPTION = 'mo_osp_settings'; - - /** - * Maximum number of entries to keep in storage - */ - const MAX_ENTRIES = 10000; - - /** - * Constructor - */ - public function __construct() { - // Schedule cleanup hook. - if ( ! wp_next_scheduled( 'mo_osp_cleanup_expired' ) ) { - wp_schedule_event( time(), 'hourly', 'mo_osp_cleanup_expired' ); - } - add_action( 'mo_osp_cleanup_expired', array( $this, 'mosp_cleanup_expired_entries' ) ); - } - - /** - * Generate a secure hash for storing identifiers. - * - * @param string $value The value to hash (phone/email/ip/browser_id). - * @return string - */ - public function mosp_hash_key( $value ) { - return hash( 'sha256', strtolower( trim( (string) $value ) ) ); - } - - /** - * Get spam data for a given key. - * - * @param string $key The hashed key. - * @return array|false - */ - public function mosp_get_spam_data( $key ) { - $option_name = self::SPAM_DATA_PREFIX . $key; - $data = get_mo_option( $option_name ); - if ( false === $data ) { - return false; - } - - if ( is_string( $data ) ) { - $data = maybe_unserialize( $data ); - } - - if ( ! is_array( $data ) ) { - return false; - } - - if ( isset( $data['attempts'] ) && ! is_array( $data['attempts'] ) ) { - $data['attempts'] = array(); - } elseif ( ! isset( $data['attempts'] ) ) { - $data['attempts'] = array(); - } - - return $data; - } - - /** - * Update spam data for a given key. - * - * @param string $key The hashed key. - * @param array $data The spam data. - * @return bool - */ - public function mosp_update_spam_data( $key, $data ) { - $option_name = self::SPAM_DATA_PREFIX . $key; - - update_mo_option( $option_name, maybe_serialize( $data ) ); - - $saved_data = $this->mosp_get_spam_data( $key ); - - $success = false; - if ( false !== $saved_data && is_array( $saved_data ) ) { - $key_fields_match = true; - if ( isset( $data['blocked_until'] ) ) { - $key_fields_match = $key_fields_match && ( isset( $saved_data['blocked_until'] ) && (int) $saved_data['blocked_until'] === (int) $data['blocked_until'] ); - } - if ( isset( $data['block_reason'] ) ) { - $key_fields_match = $key_fields_match && ( isset( $saved_data['block_reason'] ) && $saved_data['block_reason'] === $data['block_reason'] ); - } - $success = $key_fields_match; - } - - return $success; - } - - /** - * Delete spam data for a given key. - * - * @param string $key The hashed key. - * @return bool|void - */ - public function mosp_delete_spam_data( $key ) { - $option_name = self::SPAM_DATA_PREFIX . $key; - wp_cache_delete( $option_name, 'mo_osp' ); - return delete_mo_option( $option_name ); - } - - /** - * Cached settings. - * - * @var array|null - */ - private static $cached_settings = null; - - /** - * Flag to track if settings have been logged (to avoid spam in logs). - * - * @var bool - */ - private static $settings_logged = false; - - /** - * Get addon settings. - * - * @return array - */ - public function mosp_get_settings() { - if ( null !== self::$cached_settings ) { - return self::$cached_settings; - } - - $defaults = array( - 'enabled' => false, - 'cooldown_time' => 60, - 'max_attempts' => 3, - 'block_time' => 900, - 'daily_limit' => 10, - 'hourly_limit' => 5, - 'track_phone' => true, - 'track_email' => true, - 'track_ip' => true, - 'track_browser' => true, - 'whitelist_ips' => array(), - ); - - $settings = get_mo_option( self::SETTINGS_OPTION ); - - if ( false === $settings || ! is_array( $settings ) ) { - $settings = $defaults; - } else { - $settings = wp_parse_args( $settings, $defaults ); - - if ( isset( $settings['whitelist_ips'] ) && is_string( $settings['whitelist_ips'] ) ) { - if ( ! empty( $settings['whitelist_ips'] ) ) { - $split_by_newline = array_filter( array_map( 'trim', explode( "\n", $settings['whitelist_ips'] ) ) ); - if ( count( $split_by_newline ) === 1 && strpos( $split_by_newline[0], ' ' ) !== false ) { - $settings['whitelist_ips'] = array_filter( array_map( 'trim', explode( ' ', $settings['whitelist_ips'] ) ) ); - } else { - $settings['whitelist_ips'] = $split_by_newline; - } - $settings['whitelist_ips'] = array_values( $settings['whitelist_ips'] ); - } else { - $settings['whitelist_ips'] = array(); - } - } elseif ( isset( $settings['whitelist_ips'] ) && is_array( $settings['whitelist_ips'] ) ) { - $cleaned_ips = array(); - foreach ( $settings['whitelist_ips'] as $ip_item ) { - $ip_item = trim( $ip_item ); - if ( empty( $ip_item ) ) { - continue; - } - if ( strpos( $ip_item, ' ' ) !== false ) { - $split_ips = array_filter( array_map( 'trim', explode( ' ', $ip_item ) ) ); - $cleaned_ips = array_merge( $cleaned_ips, $split_ips ); - } else { - $cleaned_ips[] = $ip_item; - } - } - $settings['whitelist_ips'] = array_values( array_unique( $cleaned_ips ) ); - } elseif ( ! isset( $settings['whitelist_ips'] ) || ! is_array( $settings['whitelist_ips'] ) ) { - $settings['whitelist_ips'] = array(); - } - } - - self::$cached_settings = $settings; - - if ( ! self::$settings_logged ) { - self::$settings_logged = true; - } - - return $settings; - } - - /** - * Update addon settings. - * - * @param array $settings The settings array. - * @return bool - */ - public function mosp_update_settings( $settings ) { - update_mo_option( self::SETTINGS_OPTION, $settings ); - - self::$cached_settings = null; - - $saved_settings = get_mo_option( self::SETTINGS_OPTION ); - $success = ( $saved_settings === $settings ); - - return $success; - } - - /** - * Record an OTP attempt. - * - * @param string $identifier The identifier (phone/email/ip/browser). - * @param string $type The type of identifier. - * @return array The updated attempt data. - */ - public function mosp_record_attempt( $identifier, $type, $context = array() ) { - $key = $this->mosp_hash_key( $identifier ); - $data = $this->mosp_get_spam_data( $key ); - $now = time(); - - if ( false === $data ) { - $data = array( - 'type' => $type, - 'attempts' => array(), - 'blocked_until' => 0, - 'total_blocks' => 0, - 'created' => $now, - 'last_attempt' => $now, - ); - } else { - if ( ! isset( $data['type'] ) || 'identifier' === $data['type'] || 'unknown' === $data['type'] ) { - $data['type'] = $type; - } - if ( ! isset( $data['identifier'] ) && ! empty( $identifier ) ) { - if ( strpos( $identifier, 'email:' ) === 0 ) { - $data['identifier'] = substr( $identifier, 6 ); - } elseif ( strpos( $identifier, 'phone:' ) === 0 ) { - $data['identifier'] = substr( $identifier, 6 ); - } elseif ( strpos( $identifier, 'ip:' ) === 0 ) { - $data['identifier'] = substr( $identifier, 3 ); - } elseif ( strpos( $identifier, 'browser:' ) === 0 ) { - $data['identifier'] = substr( $identifier, 8 ); - } - } - } - - if ( is_array( $context ) ) { - if ( ! empty( $context['ip'] ) && filter_var( $context['ip'], FILTER_VALIDATE_IP ) ) { - $data['last_ip'] = $context['ip']; - } - if ( ! empty( $context['browser_id'] ) ) { - $data['last_browser'] = $context['browser_id']; - } - if ( ! empty( $context['email'] ) ) { - $data['last_email'] = strtolower( trim( (string) $context['email'] ) ); - } - if ( ! empty( $context['phone'] ) ) { - $data['last_phone'] = trim( (string) $context['phone'] ); - } - } - - $attempts_before = isset( $data['attempts'] ) && is_array( $data['attempts'] ) ? count( $data['attempts'] ) : 0; - - $data['attempts'][] = $now; - $data['last_attempt'] = $now; - - $settings = $this->mosp_get_settings(); - $time_window = MoSecurityHelper::COUNTING_WINDOW_SECONDS; - $cutoff_time = $now - $time_window; - - $data['attempts'] = array_filter( - $data['attempts'], - function ( $timestamp ) use ( $cutoff_time ) { - return $timestamp > $cutoff_time; - } - ); - - $data['attempts'] = array_values( $data['attempts'] ); - - $attempts_after = isset( $data['attempts'] ) && is_array( $data['attempts'] ) ? count( $data['attempts'] ) : 0; - - $this->mosp_update_spam_data( $key, $data ); - - return $data; - } - - /** - * Check if an identifier is blocked. - * - * @param string $identifier The identifier to check. - * @return array Block status information. - */ - public function mosp_is_blocked( $identifier ) { - $key = $this->mosp_hash_key( $identifier ); - $data = $this->mosp_get_spam_data( $key ); - $settings = $this->mosp_get_settings(); - $now = time(); - - if ( false === $data ) { - return array( - 'blocked' => false, - 'reason' => '', - 'blocked_until' => 0, - 'attempts' => 0, - ); - } - - if ( isset( $data['blocked_until'] ) && $data['blocked_until'] > 0 && $data['blocked_until'] <= $now ) { - $block_reason = isset( $data['block_reason'] ) ? $data['block_reason'] : 'unknown'; - - if ( 'max_attempts_exceeded' === $block_reason ) { - - if ( strpos( $identifier, ':' ) !== false ) { - list( $id_type, $id_value ) = explode( ':', $identifier, 2 ); - $this->mosp_mark_puzzle_required( $id_value ); - } else { - $this->mosp_mark_puzzle_required( $identifier ); - } - $attempts_before_clear = isset( $data['attempts'] ) && is_array( $data['attempts'] ) ? count( $data['attempts'] ) : 0; - $data['attempts'] = array(); - } else { - $time_window = MoSecurityHelper::COUNTING_WINDOW_SECONDS; - $cutoff_time = $now - $time_window; - - $attempts_before_clean = isset( $data['attempts'] ) && is_array( $data['attempts'] ) ? count( $data['attempts'] ) : 0; - - if ( isset( $data['attempts'] ) && is_array( $data['attempts'] ) ) { - $data['attempts'] = array_filter( - $data['attempts'], - function ( $timestamp ) use ( $cutoff_time ) { - return $timestamp > $cutoff_time; - } - ); - $data['attempts'] = array_values( $data['attempts'] ); - } - - $attempts_after_clean = isset( $data['attempts'] ) && is_array( $data['attempts'] ) ? count( $data['attempts'] ) : 0; - } - - $data['blocked_until'] = 0; - $data['block_reason'] = ''; - - $this->mosp_update_spam_data( $this->mosp_hash_key( $identifier ), $data ); - } - - if ( 0 === $data['blocked_until'] && isset( $data['block_count'] ) && $data['block_count'] > 0 ) { - if ( strpos( $identifier, ':' ) !== false ) { - list( $id_type, $id_value ) = explode( ':', $identifier, 2 ); - $existing_puzzle = $this->mosp_is_puzzle_required( $id_value ); - } else { - $existing_puzzle = $this->mosp_is_puzzle_required( $identifier ); - } - - if ( ! $existing_puzzle ) { - if ( strpos( $identifier, ':' ) !== false ) { - list( $id_type, $id_value ) = explode( ':', $identifier, 2 ); - $this->mosp_mark_puzzle_required( $id_value ); - } else { - $this->mosp_mark_puzzle_required( $identifier ); - } - } - } - - if ( $data['blocked_until'] > $now ) { - $remaining = $data['blocked_until'] - $now; - $block_reason = isset( $data['block_reason'] ) && ! empty( $data['block_reason'] ) ? $data['block_reason'] : 'temporarily_blocked'; - return array( - 'blocked' => true, - 'reason' => $block_reason, - 'blocked_until' => $data['blocked_until'], - 'attempts' => isset( $data['attempts'] ) && is_array( $data['attempts'] ) ? count( $data['attempts'] ) : 0, - ); - } - - $cooldown_time = $settings['cooldown_time']; - $attempts = isset( $data['attempts'] ) ? $data['attempts'] : array(); - - $previous_attempt = null; - if ( count( $attempts ) >= 2 ) { - $sorted_attempts = $attempts; - rsort( $sorted_attempts ); - $most_recent_attempt = $sorted_attempts[0]; - $second_to_last_attempt = $sorted_attempts[1]; - - $time_between_attempts = $most_recent_attempt - $second_to_last_attempt; - - if ( $time_between_attempts < $cooldown_time ) { - $previous_attempt = $second_to_last_attempt; - } - } - - if ( $previous_attempt && ( $now - $previous_attempt ) < $cooldown_time ) { - $time_since_previous = $now - $previous_attempt; - - $calculated_blocked_until = $previous_attempt + $cooldown_time; - - if ( ! isset( $data['blocked_until'] ) || $data['blocked_until'] !== $calculated_blocked_until ) { - if ( $calculated_blocked_until > $now ) { - $data['blocked_until'] = $calculated_blocked_until; - $data['block_reason'] = 'cooldown'; - $this->mosp_update_spam_data( $key, $data ); - } - } - - $blocked_until = isset( $data['blocked_until'] ) && $data['blocked_until'] > $now ? $data['blocked_until'] : $calculated_blocked_until; - $remaining = $blocked_until - $now; - - return array( - 'blocked' => true, - 'reason' => 'cooldown', - 'blocked_until' => $blocked_until, - 'attempts' => isset( $data['attempts'] ) && is_array( $data['attempts'] ) ? count( $data['attempts'] ) : 0, - 'remaining' => $remaining, - ); - } elseif ( $previous_attempt ) { - $time_since_previous = $now - $previous_attempt; - } - - $time_window = MoSecurityHelper::COUNTING_WINDOW_SECONDS; - $cutoff_time = $now - $time_window; - $data['attempts'] = array_filter( - $data['attempts'], - function ( $timestamp ) use ( $cutoff_time ) { - return $timestamp > $cutoff_time; - } - ); - - $max_attempts = $settings['max_attempts']; - $attempts_count = isset( $data['attempts'] ) && is_array( $data['attempts'] ) ? count( $data['attempts'] ) : 0; - - if ( $attempts_count > $max_attempts ) { - $block_time_seconds = $settings['block_time']; - $data['blocked_until'] = $now + $block_time_seconds; - $data['block_reason'] = 'max_attempts_exceeded'; - if ( ! isset( $data['total_blocks'] ) ) { - $data['total_blocks'] = 0; - } - ++$data['total_blocks']; - $this->mosp_update_spam_data( $key, $data ); - - return array( - 'blocked' => true, - 'reason' => 'max_attempts_exceeded', - 'blocked_until' => $data['blocked_until'], - 'attempts' => $attempts_count, - ); - } - - return array( - 'blocked' => false, - 'reason' => '', - 'blocked_until' => 0, - 'attempts' => isset( $data['attempts'] ) && is_array( $data['attempts'] ) ? count( $data['attempts'] ) : 0, - ); - } - - /** - * Check if identifier is whitelisted. - * - * @param string $identifier The identifier to check. - * @param string $type The type of identifier. - * @return bool - */ - public function mosp_is_whitelisted( $identifier, $type ) { - $settings = $this->mosp_get_settings(); - - switch ( $type ) { - case 'ip': - $identifier = trim( $identifier ); - - if ( empty( $identifier ) || ! filter_var( $identifier, FILTER_VALIDATE_IP ) ) { - return false; - } - - $raw_whitelist = isset( $settings['whitelist_ips'] ) ? $settings['whitelist_ips'] : array(); - - if ( is_string( $raw_whitelist ) ) { - if ( ! empty( $raw_whitelist ) ) { - $raw_whitelist = array_filter( array_map( 'trim', explode( "\n", $raw_whitelist ) ) ); - $raw_whitelist = array_values( $raw_whitelist ); - } else { - $raw_whitelist = array(); - } - } - - if ( ! empty( $raw_whitelist ) && is_array( $raw_whitelist ) ) { - $whitelist_ips = array_map( 'trim', $raw_whitelist ); - $whitelist_ips = array_filter( $whitelist_ips ); - $whitelist_ips = array_values( $whitelist_ips ); - } else { - $whitelist_ips = array(); - } - - foreach ( $whitelist_ips as $whitelist_ip ) { - $whitelist_ip = trim( $whitelist_ip ); - if ( empty( $whitelist_ip ) ) { - continue; - } - - if ( $identifier === $whitelist_ip ) { - return true; - } - - if ( strpos( $whitelist_ip, '/' ) !== false ) { - if ( $this->mosp_ip_in_range( $identifier, $whitelist_ip ) ) { - return true; - } - continue; - } - - $identifier_is_ipv6 = filter_var( $identifier, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ); - $whitelist_is_ipv6 = filter_var( $whitelist_ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ); - - if ( $identifier_is_ipv6 && $whitelist_is_ipv6 ) { - $normalized_identifier = $this->mosp_normalize_ipv6( $identifier ); - $normalized_whitelist = $this->mosp_normalize_ipv6( $whitelist_ip ); - if ( $normalized_identifier === $normalized_whitelist ) { - return true; - } - } - - $identifier_is_ipv4 = filter_var( $identifier, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ); - $whitelist_is_ipv4 = filter_var( $whitelist_ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ); - - if ( $identifier_is_ipv4 && $whitelist_is_ipv4 ) { - if ( $identifier === $whitelist_ip ) { - return true; - } - } - } - return false; - default: - return false; - } - } - - /** - * Normalize IPv6 address to canonical form. - * - * @param string $ip IPv6 address. - * @return string Normalized IPv6 address or original IP if not IPv6. - */ - private function mosp_normalize_ipv6( $ip ) { - if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { - return $ip; - } - - if ( function_exists( 'inet_pton' ) && function_exists( 'inet_ntop' ) ) { - $packed = inet_pton( $ip ); - if ( false !== $packed ) { - $normalized = inet_ntop( $packed ); - if ( false !== $normalized ) { - return strtolower( $normalized ); - } - } - } - - return strtolower( $ip ); - } - - /** - * Check if IP is in CIDR range (supports both IPv4 and IPv6). - * - * @param string $ip IP address to check. - * @param string $range CIDR range (e.g., "192.168.1.0/24" or "2001:db8::/32"). - * @return bool True if IP is in range. - */ - private function mosp_ip_in_range( $ip, $range ) { - if ( strpos( $range, '/' ) === false ) { - return $ip === $range; - } - - list( $subnet, $bits ) = explode( '/', $range ); - $bits = (int) $bits; - - if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) || ! filter_var( $subnet, FILTER_VALIDATE_IP ) ) { - return false; - } - - if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) && filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { - if ( $bits < 0 || $bits > 32 ) { - return false; - } - $ip_long = ip2long( $ip ); - $subnet_long = ip2long( $subnet ); - if ( false === $ip_long || false === $subnet_long ) { - return false; - } - $mask = -1 << ( 32 - $bits ); - $subnet_long &= $mask; - return ( $ip_long & $mask ) === $subnet_long; - } - - if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) && filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { - if ( $bits < 0 || $bits > 128 ) { - return false; - } - if ( function_exists( 'inet_pton' ) ) { - $ip_packed = inet_pton( $ip ); - $subnet_packed = inet_pton( $subnet ); - if ( false === $ip_packed || false === $subnet_packed ) { - return false; - } - - $ip_bytes = unpack( 'C*', $ip_packed ); - $subnet_bytes = unpack( 'C*', $subnet_packed ); - - $full_bytes = intval( $bits / 8 ); - $partial_bits = $bits % 8; - - for ( $i = 1; $i <= $full_bytes; $i++ ) { - if ( ! isset( $ip_bytes[ $i ] ) || ! isset( $subnet_bytes[ $i ] ) ) { - return false; - } - if ( $ip_bytes[ $i ] !== $subnet_bytes[ $i ] ) { - return false; - } - } - - if ( $partial_bits > 0 && $full_bytes < 16 ) { - $byte_index = $full_bytes + 1; - if ( ! isset( $ip_bytes[ $byte_index ] ) || ! isset( $subnet_bytes[ $byte_index ] ) ) { - return false; - } - $mask = 0xFF << ( 8 - $partial_bits ); - if ( ( $ip_bytes[ $byte_index ] & $mask ) !== ( $subnet_bytes[ $byte_index ] & $mask ) ) { - return false; - } - } - - return true; - } else { - $normalized_ip = $this->mosp_normalize_ipv6( $ip ); - $normalized_subnet = $this->mosp_normalize_ipv6( $subnet ); - if ( 128 === $bits ) { - return $normalized_ip === $normalized_subnet; - } - return false; - } - } - - return false; - } - - /** - * Mark an identifier as requiring puzzle verification. - * - * @param string $identifier The identifier to mark. - * @return bool - */ - public function mosp_mark_puzzle_required( $identifier ) { - $key = 'mo_osp_puzzle_' . $this->mosp_hash_key( $identifier ); - $current_time = time(); - $expiry = $current_time + ( 24 * 60 * 60 ); // 24 hours. - - $result = update_option( $key, $expiry ); - - return $result; - } - - /** - * Check if an identifier requires puzzle verification. - * - * @param string $identifier The identifier to check. - * @return bool - */ - public function mosp_is_puzzle_required( $identifier ) { - $key = 'mo_osp_puzzle_' . $this->mosp_hash_key( $identifier ); - $expiry = get_option( $key ); - $current_time = time(); - - if ( false === $expiry ) { - $expiry = 0; - } - - $required = ( $expiry && $expiry > $current_time ); - if ( $required ) { - $remaining_time = $expiry - $current_time; - } - - if ( $required ) { - $remaining_time = $expiry - $current_time; - return true; - } - - if ( $expiry ) { - delete_option( $key ); - } - - return false; - } - - /** - * Clear puzzle requirement for an identifier. - * - * @param string $identifier The identifier to clear. - * @return bool - */ - public function mosp_clear_puzzle_requirement( $identifier ) { - $key = 'mo_osp_puzzle_' . $this->mosp_hash_key( $identifier ); - $result = delete_option( $key ); - return $result; - } - - /** - * Check if user requires puzzle verification for any identifier. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @param string $ip IP address. - * @param string $browser_id Browser fingerprint. - * @return bool - */ - public function mosp_is_puzzle_required_for_user( $email, $phone, $ip, $browser_id ) { - $identifiers = array( - 'email' => $email, - 'phone' => $phone, - 'ip' => $ip, - 'browser' => $browser_id, - ); - - $prefixed_identifiers = array(); - if ( ! empty( $email ) ) { - $prefixed_identifiers[] = 'email:' . $email; - } - if ( ! empty( $phone ) ) { - $prefixed_identifiers[] = 'phone:' . $phone; - } - if ( ! empty( $ip ) ) { - $prefixed_identifiers[] = 'ip:' . $ip; - } - if ( ! empty( $browser_id ) ) { - $prefixed_identifiers[] = 'browser:' . $browser_id; - } - - if ( empty( $email ) && empty( $phone ) && empty( $ip ) && empty( $browser_id ) ) { - return false; - } - - foreach ( $identifiers as $type => $identifier ) { - if ( ! empty( $identifier ) ) { - $required = $this->mosp_is_puzzle_required( $identifier ); - if ( $required ) { - return true; - } - } - } - - foreach ( $prefixed_identifiers as $prefixed_id ) { - $required = $this->mosp_is_puzzle_required( $prefixed_id ); - if ( $required ) { - return true; - } - } - - return false; - } - - /** - * Cleanup expired entries. - */ - public function mosp_cleanup_expired_entries() { - global $wpdb; - - $settings = $this->mosp_get_settings(); - $now = time(); - $cutoff = $now - ( MoSecurityHelper::COUNTING_WINDOW_SECONDS * 2 ); // Keep data for 2x counting window (30 minutes). - - $deleted = $this->cleanup_spam_data( $cutoff ); - - $deleted += $this->cleanup_rate_limiting_data( $now ); - - $deleted += $this->cleanup_permanent_puzzle_flags( $now - ( 30 * 24 * 60 * 60 ) ); - - $this->mosp_prune_if_needed(); - } - - /** - * Cleanup main spam data entries. - * - * @param int $cutoff Cutoff timestamp. - * @return int Number of deleted entries. - */ - private function cleanup_spam_data( $cutoff ) { - global $wpdb; - - $cache_key = 'mosp_spam_data_option_names'; - $option_names = wp_cache_get( $cache_key, 'mo_osp' ); - if ( false === $option_names ) { - $option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->prepare( - "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", - $wpdb->esc_like( 'mo_customer_validation_' . self::SPAM_DATA_PREFIX ) . '%' - ) - ); - wp_cache_set( $cache_key, $option_names, 'mo_osp' ); - } - - $deleted = 0; - foreach ( $option_names as $db_option_name ) { - $option_key = str_replace( 'mo_customer_validation_', '', $db_option_name ); - - $data = get_mo_option( $option_key ); - - if ( is_array( $data ) ) { - if ( isset( $data['last_attempt'] ) && $data['last_attempt'] < $cutoff && - ( ! isset( $data['blocked_until'] ) || $data['blocked_until'] < time() ) ) { - delete_mo_option( $option_key ); - wp_cache_delete( $db_option_name, 'mo_osp' ); - ++$deleted; - } - } - } - - if ( $deleted > 0 ) { - wp_cache_delete( $cache_key, 'mo_osp' ); - } - - return $deleted; - } - - /** - * Cleanup rate limiting data (hourly/daily). - * - * @param int $now Current timestamp. - * @return int Number of deleted entries - */ - private function cleanup_rate_limiting_data( $now ) { - global $wpdb; - - $deleted = 0; - - $hourly_cutoff = $now - ( 2 * 60 * 60 ); - $hourly_cache = 'mosp_rate_limit_hourly_option_names'; - $hourly_options = wp_cache_get( $hourly_cache, 'mo_osp' ); - if ( false === $hourly_options ) { - $hourly_options = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->prepare( - "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", - $wpdb->esc_like( 'mo_customer_validation_mo_osp_rate_limit_hourly_' ) . '%' - ) - ); - wp_cache_set( $hourly_cache, $hourly_options, 'mo_osp' ); - } - - foreach ( $hourly_options as $db_option_name ) { - $option_key = str_replace( 'mo_customer_validation_', '', $db_option_name ); - - $data = get_mo_option( $option_key ); - if ( is_array( $data ) && isset( $data['last_attempt'] ) && $data['last_attempt'] < $hourly_cutoff ) { - delete_mo_option( $option_key ); - ++$deleted; - } - } - - $daily_cutoff = $now - ( 2 * 24 * 60 * 60 ); - $daily_cache = 'mosp_rate_limit_daily_option_names'; - $daily_options = wp_cache_get( $daily_cache, 'mo_osp' ); - if ( false === $daily_options ) { - $daily_options = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->prepare( - "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", - $wpdb->esc_like( 'mo_customer_validation_mo_osp_rate_limit_daily_' ) . '%' - ) - ); - wp_cache_set( $daily_cache, $daily_options, 'mo_osp' ); - } - - foreach ( $daily_options as $db_option_name ) { - $option_key = str_replace( 'mo_customer_validation_', '', $db_option_name ); - - $data = get_mo_option( $option_key ); - if ( is_array( $data ) && isset( $data['last_attempt'] ) && $data['last_attempt'] < $daily_cutoff ) { - delete_mo_option( $option_key ); - ++$deleted; - } - } - - if ( $deleted > 0 ) { - wp_cache_delete( $hourly_cache, 'mo_osp' ); - wp_cache_delete( $daily_cache, 'mo_osp' ); - } - - return $deleted; - } - - /** - * Cleanup permanent puzzle completion flags. - * - * @param int $cutoff Cutoff timestamp (30 days ago). - * @return int Number of deleted entries - */ - private function cleanup_permanent_puzzle_flags( $cutoff ) { - global $wpdb; - - $deleted = 0; - $cache_key = 'mosp_puzzle_completion_option_names'; - $puzzle_options = wp_cache_get( $cache_key, 'mo_osp' ); - if ( false === $puzzle_options ) { - $puzzle_options = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->prepare( - "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", - $wpdb->esc_like( 'mo_customer_validation_puzzle_ever_completed_' ) . '%' - ) - ); - wp_cache_set( $cache_key, $puzzle_options, 'mo_osp' ); - } - - foreach ( $puzzle_options as $db_option_name ) { - $option_key = str_replace( 'mo_customer_validation_', '', $db_option_name ); - - $completion_time = get_mo_option( $option_key ); - if ( is_numeric( $completion_time ) && $completion_time < $cutoff ) { - delete_mo_option( $option_key ); - ++$deleted; - } - } - - if ( $deleted > 0 ) { - wp_cache_delete( $cache_key, 'mo_osp' ); - } - - return $deleted; - } - - /** - * Prune entries if still too many. - */ - private function mosp_prune_if_needed() { - global $wpdb; - - $cache_key = 'mosp_spam_storage_total_count'; - $total_options = wp_cache_get( $cache_key, 'mo_osp' ); - if ( false === $total_options ) { - $total_options = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->prepare( - "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s", - $wpdb->esc_like( 'mo_customer_validation_' . self::SPAM_DATA_PREFIX ) . '%', - $wpdb->esc_like( 'mo_customer_validation_mo_osp_rate_limit_' ) . '%', - $wpdb->esc_like( 'mo_customer_validation_puzzle_ever_completed_' ) . '%' - ) - ); - wp_cache_set( $cache_key, $total_options, 'mo_osp' ); - } - - if ( $total_options > self::MAX_ENTRIES ) { - $this->mosp_prune_old_entries( self::MAX_ENTRIES ); - wp_cache_delete( $cache_key, 'mo_osp' ); - } - } - - /** - * Prune old entries to keep storage bounded. - * - * @param int $max_entries Maximum entries to keep. - * @return void - */ - private function mosp_prune_old_entries( $max_entries ) { - global $wpdb; - - $cache_key = 'mosp_spam_data_entries'; - $results = wp_cache_get( $cache_key, 'mo_osp' ); - if ( false === $results ) { - $results = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->prepare( - "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_id DESC", - $wpdb->esc_like( 'mo_customer_validation_' . self::SPAM_DATA_PREFIX ) . '%' - ) - ); - wp_cache_set( $cache_key, $results, 'mo_osp' ); - } - - if ( count( $results ) <= $max_entries ) { - return; - } - - $entries = array(); - foreach ( $results as $result ) { - $data = maybe_unserialize( $result->option_value ); - if ( is_array( $data ) && isset( $data['last_attempt'] ) ) { - $entries[] = array( - 'option_name' => $result->option_name, - 'last_attempt' => $data['last_attempt'], - ); - } - } - - usort( - $entries, - function ( $a, $b ) { - return $b['last_attempt'] - $a['last_attempt']; - } - ); - - $to_delete = array_slice( $entries, $max_entries ); - foreach ( $to_delete as $entry ) { - $option_key = str_replace( 'mo_customer_validation_', '', $entry['option_name'] ); - - delete_mo_option( $option_key ); - wp_cache_delete( $entry['option_name'], 'mo_osp' ); - } - - wp_cache_delete( $cache_key, 'mo_osp' ); - } - - /** - * Get masked version of identifier for logging. - * - * @param string $identifier The identifier to mask. - * @param string $type The type of identifier. - * @return string - */ - public function mosp_mask_identifier( $identifier, $type ) { - switch ( $type ) { - case 'phone': - if ( strlen( $identifier ) > 4 ) { - return str_repeat( 'X', strlen( $identifier ) - 4 ) . substr( $identifier, -4 ); - } - return $identifier; - - case 'email': - $parts = explode( '@', $identifier ); - if ( count( $parts ) === 2 ) { - $username = $parts[0]; - $domain = $parts[1]; - $masked_username = strlen( $username ) > 2 ? substr( $username, 0, 1 ) . str_repeat( '*', strlen( $username ) - 2 ) . substr( $username, -1 ) : $username; - return $masked_username . '@' . $domain; - } - return $identifier; - - case 'ip': - $parts = explode( '.', $identifier ); - if ( count( $parts ) === 4 ) { - return $parts[0] . '.' . $parts[1] . '.XXX.XXX'; - } - return $identifier; - - default: - return substr( $identifier, 0, 8 ) . '...'; - } - } - - /** - * Record attempt with timestamp (new method for integration). - * - * @param string $identifier The full identifier (e.g., 'email:user@example.com'). - * @param int $timestamp The attempt timestamp. - * @param array $context Optional context data. - * @return void - */ - public function mosp_record_attempt_with_timestamp( $identifier, $timestamp, $context = array() ) { - $key = $this->mosp_hash_key( $identifier ); - $data = $this->mosp_get_spam_data( $key ); - - if ( false === $data ) { - $data = array( - 'attempts' => array(), - 'blocked_until' => 0, - 'created' => $timestamp, - ); - } - - if ( is_array( $context ) ) { - if ( ! empty( $context['ip'] ) && filter_var( $context['ip'], FILTER_VALIDATE_IP ) ) { - $data['last_ip'] = $context['ip']; - } - if ( ! empty( $context['browser_id'] ) ) { - $data['last_browser'] = $context['browser_id']; - } - if ( ! empty( $context['email'] ) ) { - $data['last_email'] = strtolower( trim( (string) $context['email'] ) ); - } - if ( ! empty( $context['phone'] ) ) { - $data['last_phone'] = trim( (string) $context['phone'] ); - } - } - - if ( is_string( $identifier ) && strpos( $identifier, ':' ) !== false ) { - list( $id_type, $id_value ) = explode( ':', $identifier, 2 ); - $id_value = trim( (string) $id_value ); - if ( ! empty( $id_value ) ) { - if ( 'email' === $id_type ) { - $data['last_email'] = strtolower( $id_value ); - } elseif ( 'phone' === $id_type ) { - $data['last_phone'] = $id_value; - } elseif ( 'ip' === $id_type ) { - $data['last_ip'] = $id_value; - } elseif ( 'browser' === $id_type ) { - $data['last_browser'] = $id_value; - } - } - } - - if ( ! isset( $data['attempts'] ) ) { - $data['attempts'] = array(); - } - - $data['attempts'][] = $timestamp; - $data['last_attempt'] = $timestamp; - - $cutoff = $timestamp - ( 24 * 60 * 60 ); - $data['attempts'] = array_filter( - $data['attempts'], - function ( $time ) use ( $cutoff ) { - return $time > $cutoff; - } - ); - - $this->mosp_update_spam_data( $key, $data ); - } - - /** - * Get all currently blocked users. - * - * @param int $limit Maximum number of entries to return (default 100). - * @param int $offset Offset for pagination (default 0). - * @return array Array of blocked user data. - */ - public function mosp_get_all_blocked_users( $limit = 100, $offset = 0 ) { - return $this->mosp_get_blocked_users_from_rate_limits( $limit, $offset ); - } - - /** - * Delete all spam/block rows, rate-limit options, and puzzle-requirement flags (admin "clear all"). - * - * @return int Number of options deleted. - */ - public function mosp_clear_all_otp_spam_data() { - global $wpdb; - - $deleted = 0; - - $like_patterns = array( - $wpdb->esc_like( 'mo_customer_validation_' . self::SPAM_DATA_PREFIX ) . '%', - $wpdb->esc_like( 'mo_customer_validation_mo_osp_rate_limit_' ) . '%', - ); - - foreach ( $like_patterns as $like ) { - $option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->prepare( - "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", - $like - ) - ); - foreach ( $option_names as $option_name ) { - delete_site_option( $option_name ); - ++$deleted; - } - } - - $puzzle_like = $wpdb->esc_like( 'mo_osp_puzzle_' ) . '%'; - $puzzle_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->prepare( - "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", - $puzzle_like - ) - ); - foreach ( $puzzle_names as $option_name ) { - delete_option( $option_name ); - ++$deleted; - } - - wp_cache_delete( 'mosp_blocked_users_list', 'mo_osp' ); - wp_cache_delete( 'mosp_spam_data_option_names', 'mo_osp' ); - wp_cache_delete( 'mosp_uninstall_spam_option_names', 'mo_osp' ); - wp_cache_delete( 'mosp_rate_limit_hourly_options', 'mo_osp' ); - wp_cache_delete( 'mosp_rate_limit_daily_options', 'mo_osp' ); - wp_cache_delete( 'mosp_rate_limit_hourly_option_names', 'mo_osp' ); - wp_cache_delete( 'mosp_rate_limit_daily_option_names', 'mo_osp' ); - - return $deleted; - } - - /** - * Get blocked users by checking rate limit data and spam data. - * - * @param int $limit Maximum number of entries to return. - * @param int $offset Offset for pagination. - * @return array Array of blocked user data. - */ - public function mosp_get_blocked_users_from_rate_limits( $limit = 100, $offset = 0 ) { - global $wpdb; - - $now = time(); - $blocked = array(); - $settings = $this->mosp_get_settings(); - $window_types = array( 'hourly', 'daily' ); - $seen_hashes = array(); - $hash_to_identifier = array(); - $priority = array( - 'phone' => 3, - 'email' => 2, - 'ip' => 1, - 'browser' => 0, - ); - - foreach ( $window_types as $window_type ) { - $cache_key = 'mosp_rate_limit_' . $window_type . '_options'; - $option_names = wp_cache_get( $cache_key, 'mo_osp' ); - - if ( false === $option_names ) { - $option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->prepare( - "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", - $wpdb->esc_like( 'mo_customer_validation_mo_osp_rate_limit_' . $window_type . '_' ) . '%' - ) - ); - wp_cache_set( $cache_key, $option_names, 'mo_osp', 300 ); - } - - foreach ( $option_names as $db_option_name ) { - $option_key = str_replace( 'mo_customer_validation_', '', $db_option_name ); - $rate_limit_key = str_replace( self::SPAM_DATA_PREFIX, '', $option_key ); - - $key_parts = explode( '_', $rate_limit_key ); - if ( count( $key_parts ) >= 4 ) { - $identifier_hash = $key_parts[3]; - - if ( ! isset( $hash_to_identifier[ $identifier_hash ] ) ) { - $hash_to_identifier[ $identifier_hash ] = null; - } - } - } - } - - $cache_key = 'mosp_spam_data_option_names'; - $spam_option_names = wp_cache_get( $cache_key, 'mo_osp' ); - - if ( false === $spam_option_names ) { - $spam_option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->prepare( - "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", - $wpdb->esc_like( 'mo_customer_validation_' . self::SPAM_DATA_PREFIX ) . '%' - ) - ); - wp_cache_set( $cache_key, $spam_option_names, 'mo_osp', 300 ); - } - - foreach ( $spam_option_names as $db_option_name ) { - $option_key = str_replace( 'mo_customer_validation_', '', $db_option_name ); - - $hash_key = str_replace( self::SPAM_DATA_PREFIX, '', $option_key ); - - if ( strpos( $option_key, 'rate_limit_' ) !== false ) { - continue; - } - - $spam_data = $this->mosp_get_spam_data( $hash_key ); - - if ( false === $spam_data || ! is_array( $spam_data ) ) { - continue; - } - - $blocked_until = isset( $spam_data['blocked_until'] ) ? (int) $spam_data['blocked_until'] : 0; - $block_reason = isset( $spam_data['block_reason'] ) ? $spam_data['block_reason'] : ''; - - if ( $blocked_until > $now && in_array( $block_reason, array( 'hourly_limit_exceeded', 'daily_limit_exceeded', 'max_attempts_exceeded' ), true ) ) { - $remaining_time = $blocked_until - $now; - - $identifier_type = isset( $spam_data['type'] ) ? $spam_data['type'] : 'unknown'; - $identifier_value = isset( $spam_data['identifier'] ) ? $spam_data['identifier'] : ''; - - if ( ! empty( $identifier_value ) ) { - $identifier_display = $identifier_value; - } else { - $identifier_display = 'User: ' . substr( $hash_key, -8 ); - } - - if ( 'unknown' === $identifier_type || 'identifier' === $identifier_type ) { - $identifier_info = $this->mosp_infer_identifier_from_hash( $hash_key, $spam_data ); - $identifier_type = $identifier_info['type']; - if ( empty( $identifier_value ) && ! empty( $identifier_info['value'] ) ) { - $identifier_value = $identifier_info['value']; - $identifier_display = $identifier_value; - } - } - - $user_key = $block_reason . '_' . $blocked_until; - - if ( in_array( $hash_key, $seen_hashes, true ) ) { - continue; - } - - $is_duplicate = false; - foreach ( $blocked as $existing ) { - if ( $existing['block_reason'] === $block_reason && - abs( $existing['blocked_until'] - $blocked_until ) < 5 && // Within 5 seconds. - 'unknown' !== $existing['identifier_type'] && - 'unknown' !== $identifier_type ) { - $existing_priority = isset( $priority[ $existing['identifier_type'] ] ) ? $priority[ $existing['identifier_type'] ] : 0; - $current_priority = isset( $priority[ $identifier_type ] ) ? $priority[ $identifier_type ] : 0; - - if ( $current_priority > $existing_priority ) { - $blocked = array_filter( - $blocked, - function ( $item ) use ( $existing ) { - return $item['identifier_hash'] !== $existing['identifier_hash']; - } - ); - $blocked = array_values( $blocked ); - $is_duplicate = false; - break; - } else { - $is_duplicate = true; - break; - } - } - } - - if ( $is_duplicate ) { - continue; - } - - $blocked[] = array( - 'identifier_hash' => $hash_key, - 'identifier_masked' => $identifier_display, - 'identifier_type' => $identifier_type, - 'identifier_value' => $identifier_value, - 'block_reason' => $block_reason, - 'blocked_until' => $blocked_until, - 'remaining_time' => $remaining_time, - ); - - $seen_hashes[] = $hash_key; - } - } - - foreach ( $window_types as $window_type ) { - $cache_key = 'mosp_rate_limit_' . $window_type . '_options'; - $option_names = wp_cache_get( $cache_key, 'mo_osp' ); - - if ( false === $option_names ) { - $option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->prepare( - "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", - $wpdb->esc_like( 'mo_customer_validation_mo_osp_rate_limit_' . $window_type . '_' ) . '%' - ) - ); - wp_cache_set( $cache_key, $option_names, 'mo_osp', 300 ); - } - - $limit_value = 'hourly' === $window_type ? $settings['hourly_limit'] : $settings['daily_limit']; - - foreach ( $option_names as $db_option_name ) { - $option_key = str_replace( 'mo_customer_validation_', '', $db_option_name ); - - $rate_limit_key = str_replace( self::SPAM_DATA_PREFIX, '', $option_key ); - $rate_data = $this->mosp_get_spam_data( $rate_limit_key ); - - if ( false === $rate_data || ! is_array( $rate_data ) || ! isset( $rate_data['attempts'] ) || ! is_array( $rate_data['attempts'] ) ) { - continue; - } - - $window_seconds = 'hourly' === $window_type ? 3600 : 86400; - $window_start = $now - $window_seconds; - $current_attempts = count( - array_filter( - $rate_data['attempts'], - function ( $timestamp ) use ( $window_start ) { - return $timestamp > $window_start; - } - ) - ); - - if ( $current_attempts >= $limit_value ) { - $key_parts = explode( '_', $rate_limit_key ); - if ( count( $key_parts ) >= 4 ) { - $identifier_hash = $key_parts[3]; - - if ( in_array( $identifier_hash, $seen_hashes, true ) ) { - continue; - } - - $in_window = array_filter( - $rate_data['attempts'], - function ( $timestamp ) use ( $window_start ) { - return $timestamp > $window_start; - } - ); - - if ( ! empty( $in_window ) ) { - $oldest_attempt = min( $in_window ); - $reset_time = $oldest_attempt + $window_seconds; - $remaining_time = max( 0, $reset_time - $now ); - - $spam_data = $this->mosp_get_spam_data( $identifier_hash ); - - $blocked_until = 0; - $block_reason = $window_type . '_limit_exceeded'; - - if ( false !== $spam_data && is_array( $spam_data ) && isset( $spam_data['blocked_until'] ) && $spam_data['blocked_until'] > $now ) { - $blocked_until = $spam_data['blocked_until']; - $block_reason = isset( $spam_data['block_reason'] ) ? $spam_data['block_reason'] : $block_reason; - $remaining_time = $blocked_until - $now; - } - - $identifier_type = 'unknown'; - $identifier_value = ''; - $identifier_display = 'User: ' . substr( $identifier_hash, -8 ); - - if ( false !== $spam_data && is_array( $spam_data ) ) { - if ( isset( $spam_data['type'] ) ) { - $identifier_type = $spam_data['type']; - } - if ( isset( $spam_data['identifier'] ) && ! empty( $spam_data['identifier'] ) ) { - $identifier_value = $spam_data['identifier']; - $identifier_display = $identifier_value; - } - } - - if ( empty( $identifier_value ) && isset( $rate_data['identifier'] ) ) { - $rate_identifier = $rate_data['identifier']; - if ( strpos( $rate_identifier, 'phone:' ) === 0 ) { - $identifier_type = 'phone'; - $identifier_value = substr( $rate_identifier, 6 ); - $identifier_display = $identifier_value; - } elseif ( strpos( $rate_identifier, 'email:' ) === 0 ) { - $identifier_type = 'email'; - $identifier_value = substr( $rate_identifier, 6 ); - $identifier_display = $identifier_value; - } - } - - if ( 'unknown' === $identifier_type || 'identifier' === $identifier_type ) { - if ( ! empty( $spam_data['last_email'] ) ) { - $identifier_type = 'email'; - $identifier_value = $spam_data['last_email']; - $identifier_display = $identifier_value; - } elseif ( ! empty( $spam_data['last_phone'] ) ) { - $identifier_type = 'phone'; - $identifier_value = $spam_data['last_phone']; - $identifier_display = $identifier_value; - } elseif ( ! empty( $spam_data['last_ip'] ) ) { - $identifier_type = 'ip'; - $identifier_value = $spam_data['last_ip']; - $identifier_display = $identifier_value; - } elseif ( ! empty( $spam_data['last_browser'] ) ) { - $identifier_type = 'browser'; - $identifier_value = $spam_data['last_browser']; - $identifier_display = $identifier_value; - } elseif ( ! empty( $identifier_value ) && strpos( $identifier_value, '@' ) !== false ) { - $identifier_type = 'email'; - } - } - - $calculated_blocked_until = $blocked_until > 0 ? $blocked_until : ( $now + $remaining_time ); - $is_duplicate = false; - foreach ( $blocked as $existing ) { - if ( $existing['block_reason'] === $block_reason && - abs( $existing['blocked_until'] - $calculated_blocked_until ) < 5 ) { - $existing_priority = isset( $priority[ $existing['identifier_type'] ] ) ? $priority[ $existing['identifier_type'] ] : 0; - $current_priority = isset( $priority[ $identifier_type ] ) ? $priority[ $identifier_type ] : 0; - - if ( $current_priority > $existing_priority ) { - $blocked = array_filter( - $blocked, - function ( $item ) use ( $existing ) { - return $item['identifier_hash'] !== $existing['identifier_hash']; - } - ); - $blocked = array_values( $blocked ); - $is_duplicate = false; - break; - } else { - $is_duplicate = true; - break; - } - } - } - - if ( $is_duplicate ) { - continue; - } - - $blocked[] = array( - 'identifier_hash' => $identifier_hash, - 'identifier_masked' => $identifier_display, - 'identifier_type' => $identifier_type, - 'identifier_value' => $identifier_value, - 'block_reason' => $block_reason, - 'blocked_until' => $calculated_blocked_until, - 'remaining_time' => $remaining_time, - ); - - $seen_hashes[] = $identifier_hash; - } - } - } - } - } - - // Sort by remaining time (longest first). - usort( - $blocked, - function ( $a, $b ) { - return $b['remaining_time'] - $a['remaining_time']; - } - ); - - $total = count( $blocked ); - $blocked = array_slice( $blocked, $offset, $limit ); - - return array( - 'users' => $blocked, - 'total' => $total, - ); - } - - /** - * Infer identifier type and value from hash by checking rate limit data. - * - * @param string $hash The identifier hash. - * @param array $spam_data The spam data array. - * @return array Array with 'type', 'value', and 'masked' keys (masked now contains original value). - */ - private function mosp_infer_identifier_from_hash( $hash, $spam_data ) { - global $wpdb; - - $result = array( - 'type' => 'unknown', - 'value' => '', - 'masked' => 'User: ' . substr( $hash, -8 ), - ); - - if ( isset( $spam_data['identifier'] ) && ! empty( $spam_data['identifier'] ) ) { - $result['value'] = $spam_data['identifier']; - $result['masked'] = $spam_data['identifier']; - } - - if ( isset( $spam_data['type'] ) && 'identifier' !== $spam_data['type'] && 'unknown' !== $spam_data['type'] ) { - $result['type'] = $spam_data['type']; - } - - $window_types = array( 'hourly', 'daily' ); - foreach ( $window_types as $window_type ) { - $rate_limit_key = 'rate_limit_' . $window_type . '_' . $hash; - $rate_data = $this->mosp_get_spam_data( $rate_limit_key ); - - if ( false !== $rate_data && is_array( $rate_data ) ) { - if ( isset( $rate_data['identifier'] ) && ! empty( $rate_data['identifier'] ) ) { - $rate_identifier = $rate_data['identifier']; - if ( strpos( $rate_identifier, 'phone:' ) === 0 ) { - $result['type'] = 'phone'; - $result['value'] = substr( $rate_identifier, 6 ); - $result['masked'] = $result['value']; - } elseif ( strpos( $rate_identifier, 'email:' ) === 0 ) { - $result['type'] = 'email'; - $result['value'] = substr( $rate_identifier, 6 ); - $result['masked'] = $result['value']; - } - } elseif ( 'unknown' === $result['type'] ) { - if ( ! empty( $spam_data['last_email'] ) ) { - $result['type'] = 'email'; - $result['value'] = $spam_data['last_email']; - $result['masked'] = $result['value']; - } elseif ( ! empty( $spam_data['last_phone'] ) ) { - $result['type'] = 'phone'; - $result['value'] = $spam_data['last_phone']; - $result['masked'] = $result['value']; - } elseif ( ! empty( $spam_data['last_ip'] ) ) { - $result['type'] = 'ip'; - $result['value'] = $spam_data['last_ip']; - $result['masked'] = $result['value']; - } elseif ( ! empty( $spam_data['last_browser'] ) ) { - $result['type'] = 'browser'; - $result['value'] = $spam_data['last_browser']; - $result['masked'] = $result['value']; - } - } - break; - } - } - - if ( 'unknown' === $result['type'] ) { - if ( ! empty( $spam_data['last_email'] ) ) { - $result['type'] = 'email'; - $result['value'] = $spam_data['last_email']; - $result['masked'] = $result['value']; - } elseif ( ! empty( $spam_data['last_phone'] ) ) { - $result['type'] = 'phone'; - $result['value'] = $spam_data['last_phone']; - $result['masked'] = $result['value']; - } elseif ( ! empty( $spam_data['last_ip'] ) ) { - $result['type'] = 'ip'; - $result['value'] = $spam_data['last_ip']; - $result['masked'] = $result['value']; - } elseif ( ! empty( $spam_data['last_browser'] ) ) { - $result['type'] = 'browser'; - $result['value'] = $spam_data['last_browser']; - $result['masked'] = $result['value']; - } - } - - return $result; - } - } -} +<?php +/** + * OTP Spam Storage Handler + * + * @package otpspampreventer/handler + */ + +namespace OSP\Handler; + +use OSP\Traits\Instance; +use OSP\Helper\MoSecurityHelper; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +if ( ! class_exists( 'MoOtpSpamStorage' ) ) { + /** + * The class handles storage and retrieval of spam prevention data. + * Uses WordPress options table to store hashed keys and attempt data. + */ + class MoOtpSpamStorage { + + use Instance; + + /** + * Option name prefix for spam data + */ + const SPAM_DATA_PREFIX = 'mo_osp_spam_data_'; + + /** + * Option name for global settings + */ + const SETTINGS_OPTION = 'mo_osp_settings'; + + /** + * Maximum number of entries to keep in storage + */ + const MAX_ENTRIES = 10000; + + /** + * Constructor + */ + public function __construct() { + // Schedule cleanup hook. + if ( ! wp_next_scheduled( 'mo_osp_cleanup_expired' ) ) { + wp_schedule_event( time(), 'hourly', 'mo_osp_cleanup_expired' ); + } + add_action( 'mo_osp_cleanup_expired', array( $this, 'mosp_cleanup_expired_entries' ) ); + } + + /** + * Generate a secure hash for storing identifiers. + * + * @param string $value The value to hash (phone/email/ip/browser_id). + * @return string + */ + public function mosp_hash_key( $value ) { + return hash( 'sha256', strtolower( trim( (string) $value ) ) ); + } + + /** + * Get spam data for a given key. + * + * @param string $key The hashed key. + * @return array|false + */ + public function mosp_get_spam_data( $key ) { + $option_name = self::SPAM_DATA_PREFIX . $key; + $data = get_mo_option( $option_name ); + if ( false === $data ) { + return false; + } + + if ( is_string( $data ) ) { + $data = maybe_unserialize( $data ); + } + + if ( ! is_array( $data ) ) { + return false; + } + + if ( isset( $data['attempts'] ) && ! is_array( $data['attempts'] ) ) { + $data['attempts'] = array(); + } elseif ( ! isset( $data['attempts'] ) ) { + $data['attempts'] = array(); + } + + return $data; + } + + /** + * Update spam data for a given key. + * + * @param string $key The hashed key. + * @param array $data The spam data. + * @return bool + */ + public function mosp_update_spam_data( $key, $data ) { + $option_name = self::SPAM_DATA_PREFIX . $key; + + update_mo_option( $option_name, maybe_serialize( $data ) ); + + $saved_data = $this->mosp_get_spam_data( $key ); + + $success = false; + if ( false !== $saved_data && is_array( $saved_data ) ) { + $key_fields_match = true; + if ( isset( $data['blocked_until'] ) ) { + $key_fields_match = $key_fields_match && ( isset( $saved_data['blocked_until'] ) && (int) $saved_data['blocked_until'] === (int) $data['blocked_until'] ); + } + if ( isset( $data['block_reason'] ) ) { + $key_fields_match = $key_fields_match && ( isset( $saved_data['block_reason'] ) && $saved_data['block_reason'] === $data['block_reason'] ); + } + $success = $key_fields_match; + } + + return $success; + } + + /** + * Delete spam data for a given key. + * + * @param string $key The hashed key. + * @return bool|void + */ + public function mosp_delete_spam_data( $key ) { + $option_name = self::SPAM_DATA_PREFIX . $key; + wp_cache_delete( $option_name, 'mo_osp' ); + return delete_mo_option( $option_name ); + } + + /** + * Cached settings. + * + * @var array|null + */ + private static $cached_settings = null; + + /** + * Flag to track if settings have been logged (to avoid spam in logs). + * + * @var bool + */ + private static $settings_logged = false; + + /** + * Get addon settings. + * + * @return array + */ + public function mosp_get_settings() { + if ( null !== self::$cached_settings ) { + return self::$cached_settings; + } + + $defaults = array( + 'enabled' => false, + 'cooldown_time' => 60, + 'max_attempts' => 3, + 'block_time' => 900, + 'daily_limit' => 10, + 'hourly_limit' => 5, + 'track_phone' => true, + 'track_email' => true, + 'track_ip' => true, + 'track_browser' => true, + 'whitelist_ips' => array(), + ); + + $settings = get_mo_option( self::SETTINGS_OPTION ); + + if ( false === $settings || ! is_array( $settings ) ) { + $settings = $defaults; + } else { + $settings = wp_parse_args( $settings, $defaults ); + + if ( isset( $settings['whitelist_ips'] ) && is_string( $settings['whitelist_ips'] ) ) { + if ( ! empty( $settings['whitelist_ips'] ) ) { + $split_by_newline = array_filter( array_map( 'trim', explode( "\n", $settings['whitelist_ips'] ) ) ); + if ( count( $split_by_newline ) === 1 && strpos( $split_by_newline[0], ' ' ) !== false ) { + $settings['whitelist_ips'] = array_filter( array_map( 'trim', explode( ' ', $settings['whitelist_ips'] ) ) ); + } else { + $settings['whitelist_ips'] = $split_by_newline; + } + $settings['whitelist_ips'] = array_values( $settings['whitelist_ips'] ); + } else { + $settings['whitelist_ips'] = array(); + } + } elseif ( isset( $settings['whitelist_ips'] ) && is_array( $settings['whitelist_ips'] ) ) { + $cleaned_ips = array(); + foreach ( $settings['whitelist_ips'] as $ip_item ) { + $ip_item = trim( $ip_item ); + if ( empty( $ip_item ) ) { + continue; + } + if ( strpos( $ip_item, ' ' ) !== false ) { + $split_ips = array_filter( array_map( 'trim', explode( ' ', $ip_item ) ) ); + $cleaned_ips = array_merge( $cleaned_ips, $split_ips ); + } else { + $cleaned_ips[] = $ip_item; + } + } + $settings['whitelist_ips'] = array_values( array_unique( $cleaned_ips ) ); + } elseif ( ! isset( $settings['whitelist_ips'] ) || ! is_array( $settings['whitelist_ips'] ) ) { + $settings['whitelist_ips'] = array(); + } + } + + self::$cached_settings = $settings; + + if ( ! self::$settings_logged ) { + self::$settings_logged = true; + } + + return $settings; + } + + /** + * Update addon settings. + * + * @param array $settings The settings array. + * @return bool + */ + public function mosp_update_settings( $settings ) { + update_mo_option( self::SETTINGS_OPTION, $settings ); + + self::$cached_settings = null; + + $saved_settings = get_mo_option( self::SETTINGS_OPTION ); + $success = ( $saved_settings === $settings ); + + return $success; + } + + /** + * Record an OTP attempt. + * + * @param string $identifier The identifier (phone/email/ip/browser). + * @param string $type The type of identifier. + * @return array The updated attempt data. + */ + public function mosp_record_attempt( $identifier, $type, $context = array() ) { + $key = $this->mosp_hash_key( $identifier ); + $data = $this->mosp_get_spam_data( $key ); + $now = time(); + + if ( false === $data ) { + $data = array( + 'type' => $type, + 'attempts' => array(), + 'blocked_until' => 0, + 'total_blocks' => 0, + 'created' => $now, + 'last_attempt' => $now, + ); + } else { + if ( ! isset( $data['type'] ) || 'identifier' === $data['type'] || 'unknown' === $data['type'] ) { + $data['type'] = $type; + } + if ( ! isset( $data['identifier'] ) && ! empty( $identifier ) ) { + if ( strpos( $identifier, 'email:' ) === 0 ) { + $data['identifier'] = substr( $identifier, 6 ); + } elseif ( strpos( $identifier, 'phone:' ) === 0 ) { + $data['identifier'] = substr( $identifier, 6 ); + } elseif ( strpos( $identifier, 'ip:' ) === 0 ) { + $data['identifier'] = substr( $identifier, 3 ); + } elseif ( strpos( $identifier, 'browser:' ) === 0 ) { + $data['identifier'] = substr( $identifier, 8 ); + } + } + } + + if ( is_array( $context ) ) { + if ( ! empty( $context['ip'] ) && filter_var( $context['ip'], FILTER_VALIDATE_IP ) ) { + $data['last_ip'] = $context['ip']; + } + if ( ! empty( $context['browser_id'] ) ) { + $data['last_browser'] = $context['browser_id']; + } + if ( ! empty( $context['email'] ) ) { + $data['last_email'] = strtolower( trim( (string) $context['email'] ) ); + } + if ( ! empty( $context['phone'] ) ) { + $data['last_phone'] = trim( (string) $context['phone'] ); + } + } + + $attempts_before = isset( $data['attempts'] ) && is_array( $data['attempts'] ) ? count( $data['attempts'] ) : 0; + + $data['attempts'][] = $now; + $data['last_attempt'] = $now; + + $settings = $this->mosp_get_settings(); + $time_window = MoSecurityHelper::COUNTING_WINDOW_SECONDS; + $cutoff_time = $now - $time_window; + + $data['attempts'] = array_filter( + $data['attempts'], + function ( $timestamp ) use ( $cutoff_time ) { + return $timestamp > $cutoff_time; + } + ); + + $data['attempts'] = array_values( $data['attempts'] ); + + $attempts_after = isset( $data['attempts'] ) && is_array( $data['attempts'] ) ? count( $data['attempts'] ) : 0; + + $this->mosp_update_spam_data( $key, $data ); + + return $data; + } + + /** + * Check if an identifier is blocked. + * + * @param string $identifier The identifier to check. + * @return array Block status information. + */ + public function mosp_is_blocked( $identifier ) { + $key = $this->mosp_hash_key( $identifier ); + $data = $this->mosp_get_spam_data( $key ); + $settings = $this->mosp_get_settings(); + $now = time(); + + if ( false === $data ) { + return array( + 'blocked' => false, + 'reason' => '', + 'blocked_until' => 0, + 'attempts' => 0, + ); + } + + if ( isset( $data['blocked_until'] ) && $data['blocked_until'] > 0 && $data['blocked_until'] <= $now ) { + $block_reason = isset( $data['block_reason'] ) ? $data['block_reason'] : 'unknown'; + + if ( 'max_attempts_exceeded' === $block_reason ) { + + if ( strpos( $identifier, ':' ) !== false ) { + list( $id_type, $id_value ) = explode( ':', $identifier, 2 ); + $this->mosp_mark_puzzle_required( $id_value ); + } else { + $this->mosp_mark_puzzle_required( $identifier ); + } + $attempts_before_clear = isset( $data['attempts'] ) && is_array( $data['attempts'] ) ? count( $data['attempts'] ) : 0; + $data['attempts'] = array(); + } else { + $time_window = MoSecurityHelper::COUNTING_WINDOW_SECONDS; + $cutoff_time = $now - $time_window; + + $attempts_before_clean = isset( $data['attempts'] ) && is_array( $data['attempts'] ) ? count( $data['attempts'] ) : 0; + + if ( isset( $data['attempts'] ) && is_array( $data['attempts'] ) ) { + $data['attempts'] = array_filter( + $data['attempts'], + function ( $timestamp ) use ( $cutoff_time ) { + return $timestamp > $cutoff_time; + } + ); + $data['attempts'] = array_values( $data['attempts'] ); + } + + $attempts_after_clean = isset( $data['attempts'] ) && is_array( $data['attempts'] ) ? count( $data['attempts'] ) : 0; + } + + $data['blocked_until'] = 0; + $data['block_reason'] = ''; + + $this->mosp_update_spam_data( $this->mosp_hash_key( $identifier ), $data ); + } + + if ( 0 === $data['blocked_until'] && isset( $data['block_count'] ) && $data['block_count'] > 0 ) { + if ( strpos( $identifier, ':' ) !== false ) { + list( $id_type, $id_value ) = explode( ':', $identifier, 2 ); + $existing_puzzle = $this->mosp_is_puzzle_required( $id_value ); + } else { + $existing_puzzle = $this->mosp_is_puzzle_required( $identifier ); + } + + if ( ! $existing_puzzle ) { + if ( strpos( $identifier, ':' ) !== false ) { + list( $id_type, $id_value ) = explode( ':', $identifier, 2 ); + $this->mosp_mark_puzzle_required( $id_value ); + } else { + $this->mosp_mark_puzzle_required( $identifier ); + } + } + } + + if ( $data['blocked_until'] > $now ) { + $remaining = $data['blocked_until'] - $now; + $block_reason = isset( $data['block_reason'] ) && ! empty( $data['block_reason'] ) ? $data['block_reason'] : 'temporarily_blocked'; + return array( + 'blocked' => true, + 'reason' => $block_reason, + 'blocked_until' => $data['blocked_until'], + 'attempts' => isset( $data['attempts'] ) && is_array( $data['attempts'] ) ? count( $data['attempts'] ) : 0, + ); + } + + $cooldown_time = $settings['cooldown_time']; + $attempts = isset( $data['attempts'] ) ? $data['attempts'] : array(); + + $previous_attempt = null; + if ( count( $attempts ) >= 2 ) { + $sorted_attempts = $attempts; + rsort( $sorted_attempts ); + $most_recent_attempt = $sorted_attempts[0]; + $second_to_last_attempt = $sorted_attempts[1]; + + $time_between_attempts = $most_recent_attempt - $second_to_last_attempt; + + if ( $time_between_attempts < $cooldown_time ) { + $previous_attempt = $second_to_last_attempt; + } + } + + if ( $previous_attempt && ( $now - $previous_attempt ) < $cooldown_time ) { + $time_since_previous = $now - $previous_attempt; + + $calculated_blocked_until = $previous_attempt + $cooldown_time; + + if ( ! isset( $data['blocked_until'] ) || $data['blocked_until'] !== $calculated_blocked_until ) { + if ( $calculated_blocked_until > $now ) { + $data['blocked_until'] = $calculated_blocked_until; + $data['block_reason'] = 'cooldown'; + $this->mosp_update_spam_data( $key, $data ); + } + } + + $blocked_until = isset( $data['blocked_until'] ) && $data['blocked_until'] > $now ? $data['blocked_until'] : $calculated_blocked_until; + $remaining = $blocked_until - $now; + + return array( + 'blocked' => true, + 'reason' => 'cooldown', + 'blocked_until' => $blocked_until, + 'attempts' => isset( $data['attempts'] ) && is_array( $data['attempts'] ) ? count( $data['attempts'] ) : 0, + 'remaining' => $remaining, + ); + } elseif ( $previous_attempt ) { + $time_since_previous = $now - $previous_attempt; + } + + $time_window = MoSecurityHelper::COUNTING_WINDOW_SECONDS; + $cutoff_time = $now - $time_window; + $data['attempts'] = array_filter( + $data['attempts'], + function ( $timestamp ) use ( $cutoff_time ) { + return $timestamp > $cutoff_time; + } + ); + + $max_attempts = $settings['max_attempts']; + $attempts_count = isset( $data['attempts'] ) && is_array( $data['attempts'] ) ? count( $data['attempts'] ) : 0; + + if ( $attempts_count > $max_attempts ) { + $block_time_seconds = $settings['block_time']; + $data['blocked_until'] = $now + $block_time_seconds; + $data['block_reason'] = 'max_attempts_exceeded'; + if ( ! isset( $data['total_blocks'] ) ) { + $data['total_blocks'] = 0; + } + ++$data['total_blocks']; + $this->mosp_update_spam_data( $key, $data ); + + return array( + 'blocked' => true, + 'reason' => 'max_attempts_exceeded', + 'blocked_until' => $data['blocked_until'], + 'attempts' => $attempts_count, + ); + } + + return array( + 'blocked' => false, + 'reason' => '', + 'blocked_until' => 0, + 'attempts' => isset( $data['attempts'] ) && is_array( $data['attempts'] ) ? count( $data['attempts'] ) : 0, + ); + } + + /** + * Check if identifier is whitelisted. + * + * @param string $identifier The identifier to check. + * @param string $type The type of identifier. + * @return bool + */ + public function mosp_is_whitelisted( $identifier, $type ) { + $settings = $this->mosp_get_settings(); + + switch ( $type ) { + case 'ip': + $identifier = trim( $identifier ); + + if ( empty( $identifier ) || ! filter_var( $identifier, FILTER_VALIDATE_IP ) ) { + return false; + } + + $raw_whitelist = isset( $settings['whitelist_ips'] ) ? $settings['whitelist_ips'] : array(); + + if ( is_string( $raw_whitelist ) ) { + if ( ! empty( $raw_whitelist ) ) { + $raw_whitelist = array_filter( array_map( 'trim', explode( "\n", $raw_whitelist ) ) ); + $raw_whitelist = array_values( $raw_whitelist ); + } else { + $raw_whitelist = array(); + } + } + + if ( ! empty( $raw_whitelist ) && is_array( $raw_whitelist ) ) { + $whitelist_ips = array_map( 'trim', $raw_whitelist ); + $whitelist_ips = array_filter( $whitelist_ips ); + $whitelist_ips = array_values( $whitelist_ips ); + } else { + $whitelist_ips = array(); + } + + foreach ( $whitelist_ips as $whitelist_ip ) { + $whitelist_ip = trim( $whitelist_ip ); + if ( empty( $whitelist_ip ) ) { + continue; + } + + if ( $identifier === $whitelist_ip ) { + return true; + } + + if ( strpos( $whitelist_ip, '/' ) !== false ) { + if ( $this->mosp_ip_in_range( $identifier, $whitelist_ip ) ) { + return true; + } + continue; + } + + $identifier_is_ipv6 = filter_var( $identifier, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ); + $whitelist_is_ipv6 = filter_var( $whitelist_ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ); + + if ( $identifier_is_ipv6 && $whitelist_is_ipv6 ) { + $normalized_identifier = $this->mosp_normalize_ipv6( $identifier ); + $normalized_whitelist = $this->mosp_normalize_ipv6( $whitelist_ip ); + if ( $normalized_identifier === $normalized_whitelist ) { + return true; + } + } + + $identifier_is_ipv4 = filter_var( $identifier, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ); + $whitelist_is_ipv4 = filter_var( $whitelist_ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ); + + if ( $identifier_is_ipv4 && $whitelist_is_ipv4 ) { + if ( $identifier === $whitelist_ip ) { + return true; + } + } + } + return false; + default: + return false; + } + } + + /** + * Normalize IPv6 address to canonical form. + * + * @param string $ip IPv6 address. + * @return string Normalized IPv6 address or original IP if not IPv6. + */ + private function mosp_normalize_ipv6( $ip ) { + if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { + return $ip; + } + + if ( function_exists( 'inet_pton' ) && function_exists( 'inet_ntop' ) ) { + $packed = inet_pton( $ip ); + if ( false !== $packed ) { + $normalized = inet_ntop( $packed ); + if ( false !== $normalized ) { + return strtolower( $normalized ); + } + } + } + + return strtolower( $ip ); + } + + /** + * Check if IP is in CIDR range (supports both IPv4 and IPv6). + * + * @param string $ip IP address to check. + * @param string $range CIDR range (e.g., "192.168.1.0/24" or "2001:db8::/32"). + * @return bool True if IP is in range. + */ + private function mosp_ip_in_range( $ip, $range ) { + if ( strpos( $range, '/' ) === false ) { + return $ip === $range; + } + + list( $subnet, $bits ) = explode( '/', $range ); + $bits = (int) $bits; + + if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) || ! filter_var( $subnet, FILTER_VALIDATE_IP ) ) { + return false; + } + + if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) && filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { + if ( $bits < 0 || $bits > 32 ) { + return false; + } + $ip_long = ip2long( $ip ); + $subnet_long = ip2long( $subnet ); + if ( false === $ip_long || false === $subnet_long ) { + return false; + } + $mask = -1 << ( 32 - $bits ); + $subnet_long &= $mask; + return ( $ip_long & $mask ) === $subnet_long; + } + + if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) && filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { + if ( $bits < 0 || $bits > 128 ) { + return false; + } + if ( function_exists( 'inet_pton' ) ) { + $ip_packed = inet_pton( $ip ); + $subnet_packed = inet_pton( $subnet ); + if ( false === $ip_packed || false === $subnet_packed ) { + return false; + } + + $ip_bytes = unpack( 'C*', $ip_packed ); + $subnet_bytes = unpack( 'C*', $subnet_packed ); + + $full_bytes = intval( $bits / 8 ); + $partial_bits = $bits % 8; + + for ( $i = 1; $i <= $full_bytes; $i++ ) { + if ( ! isset( $ip_bytes[ $i ] ) || ! isset( $subnet_bytes[ $i ] ) ) { + return false; + } + if ( $ip_bytes[ $i ] !== $subnet_bytes[ $i ] ) { + return false; + } + } + + if ( $partial_bits > 0 && $full_bytes < 16 ) { + $byte_index = $full_bytes + 1; + if ( ! isset( $ip_bytes[ $byte_index ] ) || ! isset( $subnet_bytes[ $byte_index ] ) ) { + return false; + } + $mask = 0xFF << ( 8 - $partial_bits ); + if ( ( $ip_bytes[ $byte_index ] & $mask ) !== ( $subnet_bytes[ $byte_index ] & $mask ) ) { + return false; + } + } + + return true; + } else { + $normalized_ip = $this->mosp_normalize_ipv6( $ip ); + $normalized_subnet = $this->mosp_normalize_ipv6( $subnet ); + if ( 128 === $bits ) { + return $normalized_ip === $normalized_subnet; + } + return false; + } + } + + return false; + } + + /** + * Mark an identifier as requiring puzzle verification. + * + * @param string $identifier The identifier to mark. + * @return bool + */ + public function mosp_mark_puzzle_required( $identifier ) { + $key = 'mo_osp_puzzle_' . $this->mosp_hash_key( $identifier ); + $current_time = time(); + $expiry = $current_time + ( 24 * 60 * 60 ); // 24 hours. + + $result = update_option( $key, $expiry ); + + return $result; + } + + /** + * Check if an identifier requires puzzle verification. + * + * @param string $identifier The identifier to check. + * @return bool + */ + public function mosp_is_puzzle_required( $identifier ) { + $key = 'mo_osp_puzzle_' . $this->mosp_hash_key( $identifier ); + $expiry = get_option( $key ); + $current_time = time(); + + if ( false === $expiry ) { + $expiry = 0; + } + + $required = ( $expiry && $expiry > $current_time ); + if ( $required ) { + $remaining_time = $expiry - $current_time; + } + + if ( $required ) { + $remaining_time = $expiry - $current_time; + return true; + } + + if ( $expiry ) { + delete_option( $key ); + } + + return false; + } + + /** + * Clear puzzle requirement for an identifier. + * + * @param string $identifier The identifier to clear. + * @return bool + */ + public function mosp_clear_puzzle_requirement( $identifier ) { + $key = 'mo_osp_puzzle_' . $this->mosp_hash_key( $identifier ); + $result = delete_option( $key ); + return $result; + } + + /** + * Check if user requires puzzle verification for any identifier. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @param string $ip IP address. + * @param string $browser_id Browser fingerprint. + * @return bool + */ + public function mosp_is_puzzle_required_for_user( $email, $phone, $ip, $browser_id ) { + $identifiers = array( + 'email' => $email, + 'phone' => $phone, + 'ip' => $ip, + 'browser' => $browser_id, + ); + + $prefixed_identifiers = array(); + if ( ! empty( $email ) ) { + $prefixed_identifiers[] = 'email:' . $email; + } + if ( ! empty( $phone ) ) { + $prefixed_identifiers[] = 'phone:' . $phone; + } + if ( ! empty( $ip ) ) { + $prefixed_identifiers[] = 'ip:' . $ip; + } + if ( ! empty( $browser_id ) ) { + $prefixed_identifiers[] = 'browser:' . $browser_id; + } + + if ( empty( $email ) && empty( $phone ) && empty( $ip ) && empty( $browser_id ) ) { + return false; + } + + foreach ( $identifiers as $type => $identifier ) { + if ( ! empty( $identifier ) ) { + $required = $this->mosp_is_puzzle_required( $identifier ); + if ( $required ) { + return true; + } + } + } + + foreach ( $prefixed_identifiers as $prefixed_id ) { + $required = $this->mosp_is_puzzle_required( $prefixed_id ); + if ( $required ) { + return true; + } + } + + return false; + } + + /** + * Cleanup expired entries. + */ + public function mosp_cleanup_expired_entries() { + global $wpdb; + + $settings = $this->mosp_get_settings(); + $now = time(); + $cutoff = $now - ( MoSecurityHelper::COUNTING_WINDOW_SECONDS * 2 ); // Keep data for 2x counting window (30 minutes). + + $deleted = $this->cleanup_spam_data( $cutoff ); + + $deleted += $this->cleanup_rate_limiting_data( $now ); + + $deleted += $this->cleanup_permanent_puzzle_flags( $now - ( 30 * 24 * 60 * 60 ) ); + + $this->mosp_prune_if_needed(); + } + + /** + * Cleanup main spam data entries. + * + * @param int $cutoff Cutoff timestamp. + * @return int Number of deleted entries. + */ + private function cleanup_spam_data( $cutoff ) { + global $wpdb; + + $cache_key = 'mosp_spam_data_option_names'; + $option_names = wp_cache_get( $cache_key, 'mo_osp' ); + if ( false === $option_names ) { + $option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like( 'mo_customer_validation_' . self::SPAM_DATA_PREFIX ) . '%' + ) + ); + wp_cache_set( $cache_key, $option_names, 'mo_osp' ); + } + + $deleted = 0; + foreach ( $option_names as $db_option_name ) { + $option_key = str_replace( 'mo_customer_validation_', '', $db_option_name ); + + $data = get_mo_option( $option_key ); + + if ( is_array( $data ) ) { + if ( isset( $data['last_attempt'] ) && $data['last_attempt'] < $cutoff && + ( ! isset( $data['blocked_until'] ) || $data['blocked_until'] < time() ) ) { + delete_mo_option( $option_key ); + wp_cache_delete( $db_option_name, 'mo_osp' ); + ++$deleted; + } + } + } + + if ( $deleted > 0 ) { + wp_cache_delete( $cache_key, 'mo_osp' ); + } + + return $deleted; + } + + /** + * Cleanup rate limiting data (hourly/daily). + * + * @param int $now Current timestamp. + * @return int Number of deleted entries + */ + private function cleanup_rate_limiting_data( $now ) { + global $wpdb; + + $deleted = 0; + + $hourly_cutoff = $now - ( 2 * 60 * 60 ); + $hourly_cache = 'mosp_rate_limit_hourly_option_names'; + $hourly_options = wp_cache_get( $hourly_cache, 'mo_osp' ); + if ( false === $hourly_options ) { + $hourly_options = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like( 'mo_customer_validation_mo_osp_rate_limit_hourly_' ) . '%' + ) + ); + wp_cache_set( $hourly_cache, $hourly_options, 'mo_osp' ); + } + + foreach ( $hourly_options as $db_option_name ) { + $option_key = str_replace( 'mo_customer_validation_', '', $db_option_name ); + + $data = get_mo_option( $option_key ); + if ( is_array( $data ) && isset( $data['last_attempt'] ) && $data['last_attempt'] < $hourly_cutoff ) { + delete_mo_option( $option_key ); + ++$deleted; + } + } + + $daily_cutoff = $now - ( 2 * 24 * 60 * 60 ); + $daily_cache = 'mosp_rate_limit_daily_option_names'; + $daily_options = wp_cache_get( $daily_cache, 'mo_osp' ); + if ( false === $daily_options ) { + $daily_options = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like( 'mo_customer_validation_mo_osp_rate_limit_daily_' ) . '%' + ) + ); + wp_cache_set( $daily_cache, $daily_options, 'mo_osp' ); + } + + foreach ( $daily_options as $db_option_name ) { + $option_key = str_replace( 'mo_customer_validation_', '', $db_option_name ); + + $data = get_mo_option( $option_key ); + if ( is_array( $data ) && isset( $data['last_attempt'] ) && $data['last_attempt'] < $daily_cutoff ) { + delete_mo_option( $option_key ); + ++$deleted; + } + } + + if ( $deleted > 0 ) { + wp_cache_delete( $hourly_cache, 'mo_osp' ); + wp_cache_delete( $daily_cache, 'mo_osp' ); + } + + return $deleted; + } + + /** + * Cleanup permanent puzzle completion flags. + * + * @param int $cutoff Cutoff timestamp (30 days ago). + * @return int Number of deleted entries + */ + private function cleanup_permanent_puzzle_flags( $cutoff ) { + global $wpdb; + + $deleted = 0; + $cache_key = 'mosp_puzzle_completion_option_names'; + $puzzle_options = wp_cache_get( $cache_key, 'mo_osp' ); + if ( false === $puzzle_options ) { + $puzzle_options = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like( 'mo_customer_validation_puzzle_ever_completed_' ) . '%' + ) + ); + wp_cache_set( $cache_key, $puzzle_options, 'mo_osp' ); + } + + foreach ( $puzzle_options as $db_option_name ) { + $option_key = str_replace( 'mo_customer_validation_', '', $db_option_name ); + + $completion_time = get_mo_option( $option_key ); + if ( is_numeric( $completion_time ) && $completion_time < $cutoff ) { + delete_mo_option( $option_key ); + ++$deleted; + } + } + + if ( $deleted > 0 ) { + wp_cache_delete( $cache_key, 'mo_osp' ); + } + + return $deleted; + } + + /** + * Prune entries if still too many. + */ + private function mosp_prune_if_needed() { + global $wpdb; + + $cache_key = 'mosp_spam_storage_total_count'; + $total_options = wp_cache_get( $cache_key, 'mo_osp' ); + if ( false === $total_options ) { + $total_options = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s", + $wpdb->esc_like( 'mo_customer_validation_' . self::SPAM_DATA_PREFIX ) . '%', + $wpdb->esc_like( 'mo_customer_validation_mo_osp_rate_limit_' ) . '%', + $wpdb->esc_like( 'mo_customer_validation_puzzle_ever_completed_' ) . '%' + ) + ); + wp_cache_set( $cache_key, $total_options, 'mo_osp' ); + } + + if ( $total_options > self::MAX_ENTRIES ) { + $this->mosp_prune_old_entries( self::MAX_ENTRIES ); + wp_cache_delete( $cache_key, 'mo_osp' ); + } + } + + /** + * Prune old entries to keep storage bounded. + * + * @param int $max_entries Maximum entries to keep. + * @return void + */ + private function mosp_prune_old_entries( $max_entries ) { + global $wpdb; + + $cache_key = 'mosp_spam_data_entries'; + $results = wp_cache_get( $cache_key, 'mo_osp' ); + if ( false === $results ) { + $results = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_id DESC", + $wpdb->esc_like( 'mo_customer_validation_' . self::SPAM_DATA_PREFIX ) . '%' + ) + ); + wp_cache_set( $cache_key, $results, 'mo_osp' ); + } + + if ( count( $results ) <= $max_entries ) { + return; + } + + $entries = array(); + foreach ( $results as $result ) { + $data = maybe_unserialize( $result->option_value ); + if ( is_array( $data ) && isset( $data['last_attempt'] ) ) { + $entries[] = array( + 'option_name' => $result->option_name, + 'last_attempt' => $data['last_attempt'], + ); + } + } + + usort( + $entries, + function ( $a, $b ) { + return $b['last_attempt'] - $a['last_attempt']; + } + ); + + $to_delete = array_slice( $entries, $max_entries ); + foreach ( $to_delete as $entry ) { + $option_key = str_replace( 'mo_customer_validation_', '', $entry['option_name'] ); + + delete_mo_option( $option_key ); + wp_cache_delete( $entry['option_name'], 'mo_osp' ); + } + + wp_cache_delete( $cache_key, 'mo_osp' ); + } + + /** + * Get masked version of identifier for logging. + * + * @param string $identifier The identifier to mask. + * @param string $type The type of identifier. + * @return string + */ + public function mosp_mask_identifier( $identifier, $type ) { + switch ( $type ) { + case 'phone': + if ( strlen( $identifier ) > 4 ) { + return str_repeat( 'X', strlen( $identifier ) - 4 ) . substr( $identifier, -4 ); + } + return $identifier; + + case 'email': + $parts = explode( '@', $identifier ); + if ( count( $parts ) === 2 ) { + $username = $parts[0]; + $domain = $parts[1]; + $masked_username = strlen( $username ) > 2 ? substr( $username, 0, 1 ) . str_repeat( '*', strlen( $username ) - 2 ) . substr( $username, -1 ) : $username; + return $masked_username . '@' . $domain; + } + return $identifier; + + case 'ip': + $parts = explode( '.', $identifier ); + if ( count( $parts ) === 4 ) { + return $parts[0] . '.' . $parts[1] . '.XXX.XXX'; + } + return $identifier; + + default: + return substr( $identifier, 0, 8 ) . '...'; + } + } + + /** + * Record attempt with timestamp (new method for integration). + * + * @param string $identifier The full identifier (e.g., 'email:user@example.com'). + * @param int $timestamp The attempt timestamp. + * @param array $context Optional context data. + * @return void + */ + public function mosp_record_attempt_with_timestamp( $identifier, $timestamp, $context = array() ) { + $key = $this->mosp_hash_key( $identifier ); + $data = $this->mosp_get_spam_data( $key ); + + if ( false === $data ) { + $data = array( + 'attempts' => array(), + 'blocked_until' => 0, + 'created' => $timestamp, + ); + } + + if ( is_array( $context ) ) { + if ( ! empty( $context['ip'] ) && filter_var( $context['ip'], FILTER_VALIDATE_IP ) ) { + $data['last_ip'] = $context['ip']; + } + if ( ! empty( $context['browser_id'] ) ) { + $data['last_browser'] = $context['browser_id']; + } + if ( ! empty( $context['email'] ) ) { + $data['last_email'] = strtolower( trim( (string) $context['email'] ) ); + } + if ( ! empty( $context['phone'] ) ) { + $data['last_phone'] = trim( (string) $context['phone'] ); + } + } + + if ( is_string( $identifier ) && strpos( $identifier, ':' ) !== false ) { + list( $id_type, $id_value ) = explode( ':', $identifier, 2 ); + $id_value = trim( (string) $id_value ); + if ( ! empty( $id_value ) ) { + if ( 'email' === $id_type ) { + $data['last_email'] = strtolower( $id_value ); + } elseif ( 'phone' === $id_type ) { + $data['last_phone'] = $id_value; + } elseif ( 'ip' === $id_type ) { + $data['last_ip'] = $id_value; + } elseif ( 'browser' === $id_type ) { + $data['last_browser'] = $id_value; + } + } + } + + if ( ! isset( $data['attempts'] ) ) { + $data['attempts'] = array(); + } + + $data['attempts'][] = $timestamp; + $data['last_attempt'] = $timestamp; + + $cutoff = $timestamp - ( 24 * 60 * 60 ); + $data['attempts'] = array_filter( + $data['attempts'], + function ( $time ) use ( $cutoff ) { + return $time > $cutoff; + } + ); + + $this->mosp_update_spam_data( $key, $data ); + } + + /** + * Get all currently blocked users. + * + * @param int $limit Maximum number of entries to return (default 100). + * @param int $offset Offset for pagination (default 0). + * @return array Array of blocked user data. + */ + public function mosp_get_all_blocked_users( $limit = 100, $offset = 0 ) { + return $this->mosp_get_blocked_users_from_rate_limits( $limit, $offset ); + } + + /** + * Delete all spam/block rows, rate-limit options, and puzzle-requirement flags (admin "clear all"). + * + * @return int Number of options deleted. + */ + public function mosp_clear_all_otp_spam_data() { + global $wpdb; + + $deleted = 0; + + $like_patterns = array( + $wpdb->esc_like( 'mo_customer_validation_' . self::SPAM_DATA_PREFIX ) . '%', + $wpdb->esc_like( 'mo_customer_validation_mo_osp_rate_limit_' ) . '%', + ); + + foreach ( $like_patterns as $like ) { + $option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", + $like + ) + ); + foreach ( $option_names as $option_name ) { + delete_site_option( $option_name ); + ++$deleted; + } + } + + $puzzle_like = $wpdb->esc_like( 'mo_osp_puzzle_' ) . '%'; + $puzzle_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", + $puzzle_like + ) + ); + foreach ( $puzzle_names as $option_name ) { + delete_option( $option_name ); + ++$deleted; + } + + wp_cache_delete( 'mosp_blocked_users_list', 'mo_osp' ); + wp_cache_delete( 'mosp_spam_data_option_names', 'mo_osp' ); + wp_cache_delete( 'mosp_uninstall_spam_option_names', 'mo_osp' ); + wp_cache_delete( 'mosp_rate_limit_hourly_options', 'mo_osp' ); + wp_cache_delete( 'mosp_rate_limit_daily_options', 'mo_osp' ); + wp_cache_delete( 'mosp_rate_limit_hourly_option_names', 'mo_osp' ); + wp_cache_delete( 'mosp_rate_limit_daily_option_names', 'mo_osp' ); + + return $deleted; + } + + /** + * Get blocked users by checking rate limit data and spam data. + * + * @param int $limit Maximum number of entries to return. + * @param int $offset Offset for pagination. + * @return array Array of blocked user data. + */ + public function mosp_get_blocked_users_from_rate_limits( $limit = 100, $offset = 0 ) { + global $wpdb; + + $now = time(); + $blocked = array(); + $settings = $this->mosp_get_settings(); + $window_types = array( 'hourly', 'daily' ); + $seen_hashes = array(); + $hash_to_identifier = array(); + $priority = array( + 'phone' => 3, + 'email' => 2, + 'ip' => 1, + 'browser' => 0, + ); + + foreach ( $window_types as $window_type ) { + $cache_key = 'mosp_rate_limit_' . $window_type . '_options'; + $option_names = wp_cache_get( $cache_key, 'mo_osp' ); + + if ( false === $option_names ) { + $option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like( 'mo_customer_validation_mo_osp_rate_limit_' . $window_type . '_' ) . '%' + ) + ); + wp_cache_set( $cache_key, $option_names, 'mo_osp', 300 ); + } + + foreach ( $option_names as $db_option_name ) { + $option_key = str_replace( 'mo_customer_validation_', '', $db_option_name ); + $rate_limit_key = str_replace( self::SPAM_DATA_PREFIX, '', $option_key ); + + $key_parts = explode( '_', $rate_limit_key ); + if ( count( $key_parts ) >= 4 ) { + $identifier_hash = $key_parts[3]; + + if ( ! isset( $hash_to_identifier[ $identifier_hash ] ) ) { + $hash_to_identifier[ $identifier_hash ] = null; + } + } + } + } + + $cache_key = 'mosp_spam_data_option_names'; + $spam_option_names = wp_cache_get( $cache_key, 'mo_osp' ); + + if ( false === $spam_option_names ) { + $spam_option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like( 'mo_customer_validation_' . self::SPAM_DATA_PREFIX ) . '%' + ) + ); + wp_cache_set( $cache_key, $spam_option_names, 'mo_osp', 300 ); + } + + foreach ( $spam_option_names as $db_option_name ) { + $option_key = str_replace( 'mo_customer_validation_', '', $db_option_name ); + + $hash_key = str_replace( self::SPAM_DATA_PREFIX, '', $option_key ); + + if ( strpos( $option_key, 'rate_limit_' ) !== false ) { + continue; + } + + $spam_data = $this->mosp_get_spam_data( $hash_key ); + + if ( false === $spam_data || ! is_array( $spam_data ) ) { + continue; + } + + $blocked_until = isset( $spam_data['blocked_until'] ) ? (int) $spam_data['blocked_until'] : 0; + $block_reason = isset( $spam_data['block_reason'] ) ? $spam_data['block_reason'] : ''; + + if ( $blocked_until > $now && in_array( $block_reason, array( 'hourly_limit_exceeded', 'daily_limit_exceeded', 'max_attempts_exceeded' ), true ) ) { + $remaining_time = $blocked_until - $now; + + $identifier_type = isset( $spam_data['type'] ) ? $spam_data['type'] : 'unknown'; + $identifier_value = isset( $spam_data['identifier'] ) ? $spam_data['identifier'] : ''; + + if ( ! empty( $identifier_value ) ) { + $identifier_display = $identifier_value; + } else { + $identifier_display = 'User: ' . substr( $hash_key, -8 ); + } + + if ( 'unknown' === $identifier_type || 'identifier' === $identifier_type ) { + $identifier_info = $this->mosp_infer_identifier_from_hash( $hash_key, $spam_data ); + $identifier_type = $identifier_info['type']; + if ( empty( $identifier_value ) && ! empty( $identifier_info['value'] ) ) { + $identifier_value = $identifier_info['value']; + $identifier_display = $identifier_value; + } + } + + $user_key = $block_reason . '_' . $blocked_until; + + if ( in_array( $hash_key, $seen_hashes, true ) ) { + continue; + } + + $is_duplicate = false; + foreach ( $blocked as $existing ) { + if ( $existing['block_reason'] === $block_reason && + abs( $existing['blocked_until'] - $blocked_until ) < 5 && // Within 5 seconds. + 'unknown' !== $existing['identifier_type'] && + 'unknown' !== $identifier_type ) { + $existing_priority = isset( $priority[ $existing['identifier_type'] ] ) ? $priority[ $existing['identifier_type'] ] : 0; + $current_priority = isset( $priority[ $identifier_type ] ) ? $priority[ $identifier_type ] : 0; + + if ( $current_priority > $existing_priority ) { + $blocked = array_filter( + $blocked, + function ( $item ) use ( $existing ) { + return $item['identifier_hash'] !== $existing['identifier_hash']; + } + ); + $blocked = array_values( $blocked ); + $is_duplicate = false; + break; + } else { + $is_duplicate = true; + break; + } + } + } + + if ( $is_duplicate ) { + continue; + } + + $blocked[] = array( + 'identifier_hash' => $hash_key, + 'identifier_masked' => $identifier_display, + 'identifier_type' => $identifier_type, + 'identifier_value' => $identifier_value, + 'block_reason' => $block_reason, + 'blocked_until' => $blocked_until, + 'remaining_time' => $remaining_time, + ); + + $seen_hashes[] = $hash_key; + } + } + + foreach ( $window_types as $window_type ) { + $cache_key = 'mosp_rate_limit_' . $window_type . '_options'; + $option_names = wp_cache_get( $cache_key, 'mo_osp' ); + + if ( false === $option_names ) { + $option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like( 'mo_customer_validation_mo_osp_rate_limit_' . $window_type . '_' ) . '%' + ) + ); + wp_cache_set( $cache_key, $option_names, 'mo_osp', 300 ); + } + + $limit_value = 'hourly' === $window_type ? $settings['hourly_limit'] : $settings['daily_limit']; + + foreach ( $option_names as $db_option_name ) { + $option_key = str_replace( 'mo_customer_validation_', '', $db_option_name ); + + $rate_limit_key = str_replace( self::SPAM_DATA_PREFIX, '', $option_key ); + $rate_data = $this->mosp_get_spam_data( $rate_limit_key ); + + if ( false === $rate_data || ! is_array( $rate_data ) || ! isset( $rate_data['attempts'] ) || ! is_array( $rate_data['attempts'] ) ) { + continue; + } + + $window_seconds = 'hourly' === $window_type ? 3600 : 86400; + $window_start = $now - $window_seconds; + $current_attempts = count( + array_filter( + $rate_data['attempts'], + function ( $timestamp ) use ( $window_start ) { + return $timestamp > $window_start; + } + ) + ); + + if ( $current_attempts >= $limit_value ) { + $key_parts = explode( '_', $rate_limit_key ); + if ( count( $key_parts ) >= 4 ) { + $identifier_hash = $key_parts[3]; + + if ( in_array( $identifier_hash, $seen_hashes, true ) ) { + continue; + } + + $in_window = array_filter( + $rate_data['attempts'], + function ( $timestamp ) use ( $window_start ) { + return $timestamp > $window_start; + } + ); + + if ( ! empty( $in_window ) ) { + $oldest_attempt = min( $in_window ); + $reset_time = $oldest_attempt + $window_seconds; + $remaining_time = max( 0, $reset_time - $now ); + + $spam_data = $this->mosp_get_spam_data( $identifier_hash ); + + $blocked_until = 0; + $block_reason = $window_type . '_limit_exceeded'; + + if ( false !== $spam_data && is_array( $spam_data ) && isset( $spam_data['blocked_until'] ) && $spam_data['blocked_until'] > $now ) { + $blocked_until = $spam_data['blocked_until']; + $block_reason = isset( $spam_data['block_reason'] ) ? $spam_data['block_reason'] : $block_reason; + $remaining_time = $blocked_until - $now; + } + + $identifier_type = 'unknown'; + $identifier_value = ''; + $identifier_display = 'User: ' . substr( $identifier_hash, -8 ); + + if ( false !== $spam_data && is_array( $spam_data ) ) { + if ( isset( $spam_data['type'] ) ) { + $identifier_type = $spam_data['type']; + } + if ( isset( $spam_data['identifier'] ) && ! empty( $spam_data['identifier'] ) ) { + $identifier_value = $spam_data['identifier']; + $identifier_display = $identifier_value; + } + } + + if ( empty( $identifier_value ) && isset( $rate_data['identifier'] ) ) { + $rate_identifier = $rate_data['identifier']; + if ( strpos( $rate_identifier, 'phone:' ) === 0 ) { + $identifier_type = 'phone'; + $identifier_value = substr( $rate_identifier, 6 ); + $identifier_display = $identifier_value; + } elseif ( strpos( $rate_identifier, 'email:' ) === 0 ) { + $identifier_type = 'email'; + $identifier_value = substr( $rate_identifier, 6 ); + $identifier_display = $identifier_value; + } + } + + if ( 'unknown' === $identifier_type || 'identifier' === $identifier_type ) { + if ( ! empty( $spam_data['last_email'] ) ) { + $identifier_type = 'email'; + $identifier_value = $spam_data['last_email']; + $identifier_display = $identifier_value; + } elseif ( ! empty( $spam_data['last_phone'] ) ) { + $identifier_type = 'phone'; + $identifier_value = $spam_data['last_phone']; + $identifier_display = $identifier_value; + } elseif ( ! empty( $spam_data['last_ip'] ) ) { + $identifier_type = 'ip'; + $identifier_value = $spam_data['last_ip']; + $identifier_display = $identifier_value; + } elseif ( ! empty( $spam_data['last_browser'] ) ) { + $identifier_type = 'browser'; + $identifier_value = $spam_data['last_browser']; + $identifier_display = $identifier_value; + } elseif ( ! empty( $identifier_value ) && strpos( $identifier_value, '@' ) !== false ) { + $identifier_type = 'email'; + } + } + + $calculated_blocked_until = $blocked_until > 0 ? $blocked_until : ( $now + $remaining_time ); + $is_duplicate = false; + foreach ( $blocked as $existing ) { + if ( $existing['block_reason'] === $block_reason && + abs( $existing['blocked_until'] - $calculated_blocked_until ) < 5 ) { + $existing_priority = isset( $priority[ $existing['identifier_type'] ] ) ? $priority[ $existing['identifier_type'] ] : 0; + $current_priority = isset( $priority[ $identifier_type ] ) ? $priority[ $identifier_type ] : 0; + + if ( $current_priority > $existing_priority ) { + $blocked = array_filter( + $blocked, + function ( $item ) use ( $existing ) { + return $item['identifier_hash'] !== $existing['identifier_hash']; + } + ); + $blocked = array_values( $blocked ); + $is_duplicate = false; + break; + } else { + $is_duplicate = true; + break; + } + } + } + + if ( $is_duplicate ) { + continue; + } + + $blocked[] = array( + 'identifier_hash' => $identifier_hash, + 'identifier_masked' => $identifier_display, + 'identifier_type' => $identifier_type, + 'identifier_value' => $identifier_value, + 'block_reason' => $block_reason, + 'blocked_until' => $calculated_blocked_until, + 'remaining_time' => $remaining_time, + ); + + $seen_hashes[] = $identifier_hash; + } + } + } + } + } + + // Sort by remaining time (longest first). + usort( + $blocked, + function ( $a, $b ) { + return $b['remaining_time'] - $a['remaining_time']; + } + ); + + $total = count( $blocked ); + $blocked = array_slice( $blocked, $offset, $limit ); + + return array( + 'users' => $blocked, + 'total' => $total, + ); + } + + /** + * Infer identifier type and value from hash by checking rate limit data. + * + * @param string $hash The identifier hash. + * @param array $spam_data The spam data array. + * @return array Array with 'type', 'value', and 'masked' keys (masked now contains original value). + */ + private function mosp_infer_identifier_from_hash( $hash, $spam_data ) { + global $wpdb; + + $result = array( + 'type' => 'unknown', + 'value' => '', + 'masked' => 'User: ' . substr( $hash, -8 ), + ); + + if ( isset( $spam_data['identifier'] ) && ! empty( $spam_data['identifier'] ) ) { + $result['value'] = $spam_data['identifier']; + $result['masked'] = $spam_data['identifier']; + } + + if ( isset( $spam_data['type'] ) && 'identifier' !== $spam_data['type'] && 'unknown' !== $spam_data['type'] ) { + $result['type'] = $spam_data['type']; + } + + $window_types = array( 'hourly', 'daily' ); + foreach ( $window_types as $window_type ) { + $rate_limit_key = 'rate_limit_' . $window_type . '_' . $hash; + $rate_data = $this->mosp_get_spam_data( $rate_limit_key ); + + if ( false !== $rate_data && is_array( $rate_data ) ) { + if ( isset( $rate_data['identifier'] ) && ! empty( $rate_data['identifier'] ) ) { + $rate_identifier = $rate_data['identifier']; + if ( strpos( $rate_identifier, 'phone:' ) === 0 ) { + $result['type'] = 'phone'; + $result['value'] = substr( $rate_identifier, 6 ); + $result['masked'] = $result['value']; + } elseif ( strpos( $rate_identifier, 'email:' ) === 0 ) { + $result['type'] = 'email'; + $result['value'] = substr( $rate_identifier, 6 ); + $result['masked'] = $result['value']; + } + } elseif ( 'unknown' === $result['type'] ) { + if ( ! empty( $spam_data['last_email'] ) ) { + $result['type'] = 'email'; + $result['value'] = $spam_data['last_email']; + $result['masked'] = $result['value']; + } elseif ( ! empty( $spam_data['last_phone'] ) ) { + $result['type'] = 'phone'; + $result['value'] = $spam_data['last_phone']; + $result['masked'] = $result['value']; + } elseif ( ! empty( $spam_data['last_ip'] ) ) { + $result['type'] = 'ip'; + $result['value'] = $spam_data['last_ip']; + $result['masked'] = $result['value']; + } elseif ( ! empty( $spam_data['last_browser'] ) ) { + $result['type'] = 'browser'; + $result['value'] = $spam_data['last_browser']; + $result['masked'] = $result['value']; + } + } + break; + } + } + + if ( 'unknown' === $result['type'] ) { + if ( ! empty( $spam_data['last_email'] ) ) { + $result['type'] = 'email'; + $result['value'] = $spam_data['last_email']; + $result['masked'] = $result['value']; + } elseif ( ! empty( $spam_data['last_phone'] ) ) { + $result['type'] = 'phone'; + $result['value'] = $spam_data['last_phone']; + $result['masked'] = $result['value']; + } elseif ( ! empty( $spam_data['last_ip'] ) ) { + $result['type'] = 'ip'; + $result['value'] = $spam_data['last_ip']; + $result['masked'] = $result['value']; + } elseif ( ! empty( $spam_data['last_browser'] ) ) { + $result['type'] = 'browser'; + $result['value'] = $spam_data['last_browser']; + $result['masked'] = $result['value']; + } + } + + return $result; + } + } +} @@ -1,392 +1,392 @@ -<?php -/** - * Puzzle Helper Class - * - * Contains all puzzle generation, validation, and management functions. - * This class handles the secure puzzle system for OTP spam prevention. - * - * @package otpspampreventer/helper - */ - -namespace OSP\Helper; - -if ( ! defined( 'ABSPATH' ) ) { - exit; -} - -use OTP\Helper\MoPHPSessions; - -if ( ! class_exists( 'MoPuzzleHelper' ) ) { - /** - * Puzzle Helper Class - * - * Handles all puzzle-related operations including generation, - * validation, session management, and security checks. - */ - class MoPuzzleHelper { - - /** - * Generate a secure mathematical puzzle - * - * Creates a random mathematical puzzle with varying difficulty levels. - * The puzzle data is designed to be stored securely on the server. - * - * @return array|false Array with 'question' and 'answer' keys, or false on failure. - */ - public static function mosp_generate_secure_puzzle() { - $puzzle_templates = array( - // Simple addition. - array( - 'type' => 'add', - 'min' => 1, - 'max' => 9, - ), - // Simple subtraction. - array( - 'type' => 'sub', - 'min' => 1, - 'max' => 9, - ), - ); - - $template = $puzzle_templates[ array_rand( $puzzle_templates ) ]; - - switch ( $template['type'] ) { - case 'add': - $a = wp_rand( $template['min'], $template['max'] ); - $b = wp_rand( $template['min'], $template['max'] ); - return array( - 'question' => "{$a} + {$b}", - 'answer' => $a + $b, - ); - - case 'sub': - $a = wp_rand( $template['min'], $template['max'] ); - $b = wp_rand( $template['min'], $a ); - return array( - 'question' => "{$a} - {$b}", - 'answer' => $a - $b, - ); - - default: - $a = wp_rand( 1, 9 ); - $b = wp_rand( 1, 9 ); - return array( - 'question' => "{$a} + {$b}", - 'answer' => $a + $b, - ); - } - } - - /** - * Store puzzle data securely in session - * - * Stores puzzle data using WordPress session system with additional - * security metadata including IP, User-Agent, and timestamp. - * Uses fallback storage methods if primary session storage fails. - * - * @param string $question The puzzle question. - * @param int $answer The correct answer. - * @param string $ip Client IP address. - * @param string $user_agent Client User-Agent string. - */ - public static function mosp_store_puzzle_in_session( $question, $answer, $ip = '', $user_agent = '' ) { - $current_ip = $ip ? $ip : MoSecurityHelper::mosp_get_client_ip(); - $current_user_agent = $user_agent ? $user_agent : MoSecurityHelper::mosp_get_user_agent(); - - $puzzle_data = array( - 'question' => sanitize_text_field( wp_unslash( $question ) ), - 'answer' => intval( $answer ), - 'timestamp' => time(), - 'ip' => $current_ip, - 'user_agent' => $current_user_agent, - ); - - MoPHPSessions::unset_session( 'mo_osp_current_puzzle' ); - - MoPHPSessions::add_session_var( 'mo_osp_current_puzzle', $puzzle_data ); - - $verification = MoPHPSessions::get_session_var( 'mo_osp_current_puzzle' ); - if ( ! $verification ) { - - $user_key = self::generate_user_puzzle_key( $current_ip, $current_user_agent ); - $transient_key = 'mo_osp_puzzle_' . $user_key; - - MoPHPSessions::add_session_var( $transient_key, $puzzle_data ); - } - } - - /** - * Generate user-specific puzzle key for fallback storage - * - * @param string $ip Client IP address. - * @param string $user_agent Client User-Agent string. - * @return string Unique key for this user. - */ - private static function generate_user_puzzle_key( $ip, $user_agent ) { - $unique_data = $ip . '|' . substr( $user_agent, 0, 100 ) . '|' . ( isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) : '' ); - return substr( md5( $unique_data ), 0, 16 ); - } - - /** - * Get puzzle data from storage (primary session or fallback session) - * - * @return array|false Puzzle data or false if not found. - */ - private static function get_puzzle_data_from_storage() { - $session_puzzle_data = MoPHPSessions::get_session_var( 'mo_osp_current_puzzle' ); - - if ( $session_puzzle_data && is_array( $session_puzzle_data ) ) { - return $session_puzzle_data; - } - - $current_ip = MoSecurityHelper::mosp_get_client_ip(); - $current_user_agent = MoSecurityHelper::mosp_get_user_agent(); - $user_key = self::generate_user_puzzle_key( $current_ip, $current_user_agent ); - $transient_key = 'mo_osp_puzzle_' . $user_key; - - $fallback_data = MoPHPSessions::get_session_var( $transient_key ); - if ( $fallback_data && is_array( $fallback_data ) ) { - return $fallback_data; - } - - return false; - } - - /** - * Validate puzzle answer using session-stored data - * - * Validates the user's answer against securely stored puzzle data - * with comprehensive security checks including IP validation, - * User-Agent validation, and timestamp validation. - * - * @param int $user_answer The user's submitted answer. - * @return bool True if answer is correct and all security checks pass. - */ - public static function mosp_validate_puzzle_answer_from_session( $user_answer ) { - - $session_puzzle_data = self::get_puzzle_data_from_storage(); - - if ( ! $session_puzzle_data || ! is_array( $session_puzzle_data ) ) { - return false; - } - - if ( ! isset( $session_puzzle_data['question'] ) || ! isset( $session_puzzle_data['answer'] ) || ! isset( $session_puzzle_data['timestamp'] ) ) { - return false; - } - - $puzzle_age = time() - $session_puzzle_data['timestamp']; - - if ( $puzzle_age > 600 ) { - self::mosp_clear_puzzle_from_session(); - return false; - } - - $current_ip = MoSecurityHelper::mosp_get_client_ip(); - $current_user_agent = MoSecurityHelper::mosp_get_user_agent(); - - $expected_answer = intval( $session_puzzle_data['answer'] ); - $user_answer_int = intval( $user_answer ); - $is_correct = ( $user_answer_int === $expected_answer ); - - if ( $is_correct ) { - self::mosp_clear_puzzle_from_session(); - } - - return $is_correct; - } - - /** - * Clear puzzle data from session - * - * Removes puzzle data from both primary and fallback storage for security cleanup. - */ - public static function mosp_clear_puzzle_from_session() { - MoPHPSessions::unset_session( 'mo_osp_current_puzzle' ); - - $current_ip = MoSecurityHelper::mosp_get_client_ip(); - $current_user_agent = MoSecurityHelper::mosp_get_user_agent(); - $user_key = self::generate_user_puzzle_key( $current_ip, $current_user_agent ); - $transient_key = 'mo_osp_puzzle_' . $user_key; - - MoPHPSessions::unset_session( $transient_key ); - } - - /** - * Check if puzzle data exists in session. - * - * @return bool True if valid puzzle data exists in session. - */ - public static function mosp_has_puzzle_in_session() { - $session_puzzle_data = self::get_puzzle_data_from_storage(); - - if ( ! $session_puzzle_data || ! is_array( $session_puzzle_data ) ) { - return false; - } - - if ( isset( $session_puzzle_data['timestamp'] ) ) { - $puzzle_age = time() - $session_puzzle_data['timestamp']; - if ( $puzzle_age > 600 ) { - self::mosp_clear_puzzle_from_session(); - return false; - } - } - - $has_required_fields = isset( $session_puzzle_data['question'] ) && isset( $session_puzzle_data['answer'] ); - - return $has_required_fields; - } - - /** - * Generate puzzle question as a blurred image. - * - * Creates an image with the puzzle question text, adds distortion effects - * to prevent OCR/bot bypass, and returns the image as a data URI. - * - * @param string $question The puzzle question text. - * @return string|false The image data URI or false on failure. - */ - public static function mosp_generate_puzzle_image( $question ) { - if ( ! function_exists( 'imagecreatetruecolor' ) ) { - return false; - } - - $width = 280; - $height = 60; - - $image = imagecreatetruecolor( $width, $height ); - if ( ! $image ) { - return false; - } - - $bg_color = imagecolorallocate( $image, 255, 255, 255 ); - $text_color = imagecolorallocate( $image, 50, 50, 50 ); - $noise_color = imagecolorallocate( $image, 200, 200, 200 ); - - imagefill( $image, 0, 0, $bg_color ); - - for ( $i = 0; $i < 80; $i++ ) { - imagesetpixel( $image, wp_rand( 0, $width ), wp_rand( 0, $height ), $noise_color ); - } - - for ( $i = 0; $i < 2; $i++ ) { - $line_color = imagecolorallocate( $image, wp_rand( 220, 240 ), wp_rand( 220, 240 ), wp_rand( 220, 240 ) ); - imageline( $image, wp_rand( 0, $width ), wp_rand( 0, $height ), wp_rand( 0, $width ), wp_rand( 0, $height ), $line_color ); - } - - $font_path = ''; - $font_size = 20; - - $possible_fonts = array( - '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', - '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf', - 'C:/Windows/Fonts/arial.ttf', - 'C:/Windows/Fonts/verdana.ttf', - '/System/Library/Fonts/Helvetica.ttc', - ); - - foreach ( $possible_fonts as $font ) { - if ( file_exists( $font ) ) { - $font_path = $font; - break; - } - } - - $x = 15; - $y = 40; - - if ( $font_path && function_exists( 'imagettftext' ) ) { - $angle = wp_rand( -5, 5 ); - imagettftext( $image, $font_size, $angle, $x, $y, $text_color, $font_path, $question ); - } else { - $font = 5; - imagestring( $image, $font, $x, 20, $question, $text_color ); - } - - if ( function_exists( 'imagefilter' ) ) { - imagefilter( $image, IMG_FILTER_GAUSSIAN_BLUR ); - imagefilter( $image, IMG_FILTER_SMOOTH, 2 ); - } - - ob_start(); - imagepng( $image, null, 9 ); - $image_data = ob_get_clean(); - - imagedestroy( $image ); - - if ( $image_data ) { - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Needed for data URI encoding. - return 'data:image/png;base64,' . base64_encode( $image_data ); - } - - return false; - } - - /** - * Render the puzzle popup HTML. - * - * Outputs the puzzle verification popup HTML that can be used on any page. - * This method handles all the HTML markup for the puzzle modal/overlay. - * - * @return void Outputs HTML directly - */ - public static function mosp_render_puzzle_popup() { - ?> - <div id="mo-osp-puzzle-overlay" class="mo-osp-puzzle-overlay mo-osp-hidden"> - <div class="mo-osp-puzzle-popup"> - <div class="mo-osp-puzzle-header"> - <h3 class="mo-osp-puzzle-title"> - <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M12 1L3 5V11C3 16.55 6.84 21.74 12 23C17.16 21.74 21 16.55 21 11V5L12 1ZM10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z" fill="currentColor"/> - </svg> - <?php echo esc_html( __( 'Security Verification', 'miniorange-otp-verification' ) ); ?> - </h3> - <button type="button" class="mo-osp-puzzle-close" aria-label="<?php echo esc_attr( __( 'Close', 'miniorange-otp-verification' ) ); ?>"> - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12L19 6.41Z" fill="currentColor"/> - </svg> - </button> - </div> - - <div class="mo-osp-puzzle-body"> - <div class="mo-osp-puzzle-message"> - <p><?php echo esc_html( __( 'For security purposes, please solve this simple puzzle to verify you are human before sending an OTP.', 'miniorange-otp-verification' ) ); ?></p> - </div> - - <div class="mo-osp-puzzle-question"> - <div class="mo-osp-puzzle-equation"> - <img id="mo-osp-puzzle-image" class="mo-osp-puzzle-image" src="" alt="<?php echo esc_attr( __( 'Puzzle Question', 'miniorange-otp-verification' ) ); ?>" style="display: none;" /> - <span id="mo-osp-puzzle-text" class="mo-osp-equation-text" style="display: none;"></span> - <span class="mo-osp-equals">=</span> - <input type="number" id="mo-osp-puzzle-answer" class="mo-osp-puzzle-input" placeholder="?" autocomplete="off" /> - </div> - </div> - - <div class="mo-osp-puzzle-error" id="mo-osp-puzzle-error" style="display: none;"> - <svg class="mo-osp-error-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M12 2C6.48 2 2 6.48 2 12S6.48 22 12 22 22 17.52 22 12 17.52 2 12 2ZM13 17H11V15H13V17ZM13 13H11V7H13V13Z" fill="currentColor"/> - </svg> - <span id="mo-osp-puzzle-error-text"></span> - </div> - </div> - - <div class="mo-osp-puzzle-footer"> - <button type="button" id="mo-osp-puzzle-refresh" class="mo-osp-puzzle-btn mo-osp-btn-secondary"> - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M17.65 6.35C16.2 4.9 14.21 4 12 4C7.58 4 4 7.58 4 12S7.58 20 12 20C15.73 20 18.84 17.45 19.73 14H17.65C16.83 16.33 14.61 18 12 18C8.69 18 6 15.31 6 12S8.69 6 12 6C13.66 6 15.14 6.69 16.22 7.78L13 11H20V4L17.65 6.35Z" fill="currentColor"/> - </svg> - <?php echo esc_html( __( 'New Puzzle', 'miniorange-otp-verification' ) ); ?> - </button> - <button type="button" id="mo-osp-puzzle-verify" class="mo-osp-puzzle-btn mo-osp-btn-primary"> - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M9 16.17L4.83 12L3.41 13.41L9 19L21 7L19.59 5.59L9 16.17Z" fill="currentColor"/> - </svg> - <?php echo esc_html( __( 'Verify & Send OTP', 'miniorange-otp-verification' ) ); ?> - </button> - </div> - </div> - </div> - <?php - } - } -} +<?php +/** + * Puzzle Helper Class + * + * Contains all puzzle generation, validation, and management functions. + * This class handles the secure puzzle system for OTP spam prevention. + * + * @package otpspampreventer/helper + */ + +namespace OSP\Helper; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +use OTP\Helper\MoPHPSessions; + +if ( ! class_exists( 'MoPuzzleHelper' ) ) { + /** + * Puzzle Helper Class + * + * Handles all puzzle-related operations including generation, + * validation, session management, and security checks. + */ + class MoPuzzleHelper { + + /** + * Generate a secure mathematical puzzle + * + * Creates a random mathematical puzzle with varying difficulty levels. + * The puzzle data is designed to be stored securely on the server. + * + * @return array|false Array with 'question' and 'answer' keys, or false on failure. + */ + public static function mosp_generate_secure_puzzle() { + $puzzle_templates = array( + // Simple addition. + array( + 'type' => 'add', + 'min' => 1, + 'max' => 9, + ), + // Simple subtraction. + array( + 'type' => 'sub', + 'min' => 1, + 'max' => 9, + ), + ); + + $template = $puzzle_templates[ array_rand( $puzzle_templates ) ]; + + switch ( $template['type'] ) { + case 'add': + $a = wp_rand( $template['min'], $template['max'] ); + $b = wp_rand( $template['min'], $template['max'] ); + return array( + 'question' => "{$a} + {$b}", + 'answer' => $a + $b, + ); + + case 'sub': + $a = wp_rand( $template['min'], $template['max'] ); + $b = wp_rand( $template['min'], $a ); + return array( + 'question' => "{$a} - {$b}", + 'answer' => $a - $b, + ); + + default: + $a = wp_rand( 1, 9 ); + $b = wp_rand( 1, 9 ); + return array( + 'question' => "{$a} + {$b}", + 'answer' => $a + $b, + ); + } + } + + /** + * Store puzzle data securely in session + * + * Stores puzzle data using WordPress session system with additional + * security metadata including IP, User-Agent, and timestamp. + * Uses fallback storage methods if primary session storage fails. + * + * @param string $question The puzzle question. + * @param int $answer The correct answer. + * @param string $ip Client IP address. + * @param string $user_agent Client User-Agent string. + */ + public static function mosp_store_puzzle_in_session( $question, $answer, $ip = '', $user_agent = '' ) { + $current_ip = $ip ? $ip : MoSecurityHelper::mosp_get_client_ip(); + $current_user_agent = $user_agent ? $user_agent : MoSecurityHelper::mosp_get_user_agent(); + + $puzzle_data = array( + 'question' => sanitize_text_field( wp_unslash( $question ) ), + 'answer' => intval( $answer ), + 'timestamp' => time(), + 'ip' => $current_ip, + 'user_agent' => $current_user_agent, + ); + + MoPHPSessions::unset_session( 'mo_osp_current_puzzle' ); + + MoPHPSessions::add_session_var( 'mo_osp_current_puzzle', $puzzle_data ); + + $verification = MoPHPSessions::get_session_var( 'mo_osp_current_puzzle' ); + if ( ! $verification ) { + + $user_key = self::generate_user_puzzle_key( $current_ip, $current_user_agent ); + $transient_key = 'mo_osp_puzzle_' . $user_key; + + MoPHPSessions::add_session_var( $transient_key, $puzzle_data ); + } + } + + /** + * Generate user-specific puzzle key for fallback storage + * + * @param string $ip Client IP address. + * @param string $user_agent Client User-Agent string. + * @return string Unique key for this user. + */ + private static function generate_user_puzzle_key( $ip, $user_agent ) { + $unique_data = $ip . '|' . substr( $user_agent, 0, 100 ) . '|' . ( isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) : '' ); + return substr( md5( $unique_data ), 0, 16 ); + } + + /** + * Get puzzle data from storage (primary session or fallback session) + * + * @return array|false Puzzle data or false if not found. + */ + private static function get_puzzle_data_from_storage() { + $session_puzzle_data = MoPHPSessions::get_session_var( 'mo_osp_current_puzzle' ); + + if ( $session_puzzle_data && is_array( $session_puzzle_data ) ) { + return $session_puzzle_data; + } + + $current_ip = MoSecurityHelper::mosp_get_client_ip(); + $current_user_agent = MoSecurityHelper::mosp_get_user_agent(); + $user_key = self::generate_user_puzzle_key( $current_ip, $current_user_agent ); + $transient_key = 'mo_osp_puzzle_' . $user_key; + + $fallback_data = MoPHPSessions::get_session_var( $transient_key ); + if ( $fallback_data && is_array( $fallback_data ) ) { + return $fallback_data; + } + + return false; + } + + /** + * Validate puzzle answer using session-stored data + * + * Validates the user's answer against securely stored puzzle data + * with comprehensive security checks including IP validation, + * User-Agent validation, and timestamp validation. + * + * @param int $user_answer The user's submitted answer. + * @return bool True if answer is correct and all security checks pass. + */ + public static function mosp_validate_puzzle_answer_from_session( $user_answer ) { + + $session_puzzle_data = self::get_puzzle_data_from_storage(); + + if ( ! $session_puzzle_data || ! is_array( $session_puzzle_data ) ) { + return false; + } + + if ( ! isset( $session_puzzle_data['question'] ) || ! isset( $session_puzzle_data['answer'] ) || ! isset( $session_puzzle_data['timestamp'] ) ) { + return false; + } + + $puzzle_age = time() - $session_puzzle_data['timestamp']; + + if ( $puzzle_age > 600 ) { + self::mosp_clear_puzzle_from_session(); + return false; + } + + $current_ip = MoSecurityHelper::mosp_get_client_ip(); + $current_user_agent = MoSecurityHelper::mosp_get_user_agent(); + + $expected_answer = intval( $session_puzzle_data['answer'] ); + $user_answer_int = intval( $user_answer ); + $is_correct = ( $user_answer_int === $expected_answer ); + + if ( $is_correct ) { + self::mosp_clear_puzzle_from_session(); + } + + return $is_correct; + } + + /** + * Clear puzzle data from session + * + * Removes puzzle data from both primary and fallback storage for security cleanup. + */ + public static function mosp_clear_puzzle_from_session() { + MoPHPSessions::unset_session( 'mo_osp_current_puzzle' ); + + $current_ip = MoSecurityHelper::mosp_get_client_ip(); + $current_user_agent = MoSecurityHelper::mosp_get_user_agent(); + $user_key = self::generate_user_puzzle_key( $current_ip, $current_user_agent ); + $transient_key = 'mo_osp_puzzle_' . $user_key; + + MoPHPSessions::unset_session( $transient_key ); + } + + /** + * Check if puzzle data exists in session. + * + * @return bool True if valid puzzle data exists in session. + */ + public static function mosp_has_puzzle_in_session() { + $session_puzzle_data = self::get_puzzle_data_from_storage(); + + if ( ! $session_puzzle_data || ! is_array( $session_puzzle_data ) ) { + return false; + } + + if ( isset( $session_puzzle_data['timestamp'] ) ) { + $puzzle_age = time() - $session_puzzle_data['timestamp']; + if ( $puzzle_age > 600 ) { + self::mosp_clear_puzzle_from_session(); + return false; + } + } + + $has_required_fields = isset( $session_puzzle_data['question'] ) && isset( $session_puzzle_data['answer'] ); + + return $has_required_fields; + } + + /** + * Generate puzzle question as a blurred image. + * + * Creates an image with the puzzle question text, adds distortion effects + * to prevent OCR/bot bypass, and returns the image as a data URI. + * + * @param string $question The puzzle question text. + * @return string|false The image data URI or false on failure. + */ + public static function mosp_generate_puzzle_image( $question ) { + if ( ! function_exists( 'imagecreatetruecolor' ) ) { + return false; + } + + $width = 280; + $height = 60; + + $image = imagecreatetruecolor( $width, $height ); + if ( ! $image ) { + return false; + } + + $bg_color = imagecolorallocate( $image, 255, 255, 255 ); + $text_color = imagecolorallocate( $image, 50, 50, 50 ); + $noise_color = imagecolorallocate( $image, 200, 200, 200 ); + + imagefill( $image, 0, 0, $bg_color ); + + for ( $i = 0; $i < 80; $i++ ) { + imagesetpixel( $image, wp_rand( 0, $width ), wp_rand( 0, $height ), $noise_color ); + } + + for ( $i = 0; $i < 2; $i++ ) { + $line_color = imagecolorallocate( $image, wp_rand( 220, 240 ), wp_rand( 220, 240 ), wp_rand( 220, 240 ) ); + imageline( $image, wp_rand( 0, $width ), wp_rand( 0, $height ), wp_rand( 0, $width ), wp_rand( 0, $height ), $line_color ); + } + + $font_path = ''; + $font_size = 20; + + $possible_fonts = array( + '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', + '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf', + 'C:/Windows/Fonts/arial.ttf', + 'C:/Windows/Fonts/verdana.ttf', + '/System/Library/Fonts/Helvetica.ttc', + ); + + foreach ( $possible_fonts as $font ) { + if ( file_exists( $font ) ) { + $font_path = $font; + break; + } + } + + $x = 15; + $y = 40; + + if ( $font_path && function_exists( 'imagettftext' ) ) { + $angle = wp_rand( -5, 5 ); + imagettftext( $image, $font_size, $angle, $x, $y, $text_color, $font_path, $question ); + } else { + $font = 5; + imagestring( $image, $font, $x, 20, $question, $text_color ); + } + + if ( function_exists( 'imagefilter' ) ) { + imagefilter( $image, IMG_FILTER_GAUSSIAN_BLUR ); + imagefilter( $image, IMG_FILTER_SMOOTH, 2 ); + } + + ob_start(); + imagepng( $image, null, 9 ); + $image_data = ob_get_clean(); + + imagedestroy( $image ); + + if ( $image_data ) { + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Needed for data URI encoding. + return 'data:image/png;base64,' . base64_encode( $image_data ); + } + + return false; + } + + /** + * Render the puzzle popup HTML. + * + * Outputs the puzzle verification popup HTML that can be used on any page. + * This method handles all the HTML markup for the puzzle modal/overlay. + * + * @return void Outputs HTML directly + */ + public static function mosp_render_puzzle_popup() { + ?> + <div id="mo-osp-puzzle-overlay" class="mo-osp-puzzle-overlay mo-osp-hidden"> + <div class="mo-osp-puzzle-popup"> + <div class="mo-osp-puzzle-header"> + <h3 class="mo-osp-puzzle-title"> + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M12 1L3 5V11C3 16.55 6.84 21.74 12 23C17.16 21.74 21 16.55 21 11V5L12 1ZM10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z" fill="currentColor"/> + </svg> + <?php echo esc_html( __( 'Security Verification', 'miniorange-otp-verification' ) ); ?> + </h3> + <button type="button" class="mo-osp-puzzle-close" aria-label="<?php echo esc_attr( __( 'Close', 'miniorange-otp-verification' ) ); ?>"> + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12L19 6.41Z" fill="currentColor"/> + </svg> + </button> + </div> + + <div class="mo-osp-puzzle-body"> + <div class="mo-osp-puzzle-message"> + <p><?php echo esc_html( __( 'For security purposes, please solve this simple puzzle to verify you are human before sending an OTP.', 'miniorange-otp-verification' ) ); ?></p> + </div> + + <div class="mo-osp-puzzle-question"> + <div class="mo-osp-puzzle-equation"> + <img id="mo-osp-puzzle-image" class="mo-osp-puzzle-image" src="" alt="<?php echo esc_attr( __( 'Puzzle Question', 'miniorange-otp-verification' ) ); ?>" style="display: none;" /> + <span id="mo-osp-puzzle-text" class="mo-osp-equation-text" style="display: none;"></span> + <span class="mo-osp-equals">=</span> + <input type="number" id="mo-osp-puzzle-answer" class="mo-osp-puzzle-input" placeholder="?" autocomplete="off" /> + </div> + </div> + + <div class="mo-osp-puzzle-error" id="mo-osp-puzzle-error" style="display: none;"> + <svg class="mo-osp-error-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M12 2C6.48 2 2 6.48 2 12S6.48 22 12 22 22 17.52 22 12 17.52 2 12 2ZM13 17H11V15H13V17ZM13 13H11V7H13V13Z" fill="currentColor"/> + </svg> + <span id="mo-osp-puzzle-error-text"></span> + </div> + </div> + + <div class="mo-osp-puzzle-footer"> + <button type="button" id="mo-osp-puzzle-refresh" class="mo-osp-puzzle-btn mo-osp-btn-secondary"> + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M17.65 6.35C16.2 4.9 14.21 4 12 4C7.58 4 4 7.58 4 12S7.58 20 12 20C15.73 20 18.84 17.45 19.73 14H17.65C16.83 16.33 14.61 18 12 18C8.69 18 6 15.31 6 12S8.69 6 12 6C13.66 6 15.14 6.69 16.22 7.78L13 11H20V4L17.65 6.35Z" fill="currentColor"/> + </svg> + <?php echo esc_html( __( 'New Puzzle', 'miniorange-otp-verification' ) ); ?> + </button> + <button type="button" id="mo-osp-puzzle-verify" class="mo-osp-puzzle-btn mo-osp-btn-primary"> + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M9 16.17L4.83 12L3.41 13.41L9 19L21 7L19.59 5.59L9 16.17Z" fill="currentColor"/> + </svg> + <?php echo esc_html( __( 'Verify & Send OTP', 'miniorange-otp-verification' ) ); ?> + </button> + </div> + </div> + </div> + <?php + } + } +} @@ -1,275 +1,275 @@ -<?php -/** - * Security Helper Class - * - * Contains all security-related validation and verification functions. - * This class handles nonce verification, token management, and security checks. - * - * @package otpspampreventer/helper - */ - -namespace OSP\Helper; - -if ( ! defined( 'ABSPATH' ) ) { - exit; -} - -use OTP\Helper\MoPHPSessions; -use OTP\Helper\MoUtility; - -if ( ! class_exists( 'MoSecurityHelper' ) ) { - /** - * Security Helper Class - * - * Handles all security-related operations including nonce verification, - * token generation, verification token management, and security validation. - */ - class MoSecurityHelper { - - /** - * Counting window for spam detection (15 minutes in seconds) - * - * This constant defines the time window used for counting attempts - * in spam detection algorithms. Change this value in one place to - * modify the counting window across the entire addon. - */ - const COUNTING_WINDOW_SECONDS = 900; // 15 minutes (15 * 60) - - /** - * Normalize email or phone the same way as puzzle verify / OTP send paths. - * - * @param string $value Raw value. - * @param bool $is_email Whether this value is an email field. - * @return string - */ - private static function mosp_identifier_for_puzzle_key( $value, $is_email ) { - $value = trim( (string) $value ); - if ( '' === $value ) { - return ''; - } - if ( $is_email ) { - return sanitize_email( $value ); - } - return MoUtility::process_phone_number( $value ); - } - - /** - * All user-identifier strings that may have been used to build the puzzle token - * (WooCommerce phone checkout clears email in the spam hook but puzzle AJAX often sends both). - * - * @param string $user_email Email from current request context. - * @param string $phone_number Phone from current request context. - * @return string[] Unique normalized identifiers. - */ - private static function mosp_collect_puzzle_verification_identifiers( $user_email, $phone_number ) { - $pairs = array( - array( $user_email, true ), - array( $phone_number, false ), - array( MoPHPSessions::get_session_var( 'user_email' ), true ), - array( MoPHPSessions::get_session_var( 'phone_number_mo' ), false ), - ); - $ids = array(); - foreach ( $pairs as $pair ) { - $n = self::mosp_identifier_for_puzzle_key( $pair[0], $pair[1] ); - if ( '' !== $n ) { - $ids[] = $n; - } - } - return array_values( array_unique( $ids ) ); - } - - /** - * Build session/token key string for one identifier (must match generate path). - * - * @param string $user_identifier Normalized email or phone. - * @return string - */ - private static function mosp_build_puzzle_verification_key_string( $user_identifier ) { - $session_id = session_id() ? session_id() : wp_get_session_token(); - $ip = self::mosp_get_client_ip(); - return 'mo_osp_puzzle_verified_' . md5( $user_identifier . $session_id . $ip ); - } - - /** - * Every puzzle verification key to try for this request (email-only, phone-only, session fallbacks). - * - * @param string $user_email Email address. - * @param string $phone_number Phone number. - * @return string[] - */ - public static function mosp_get_puzzle_verification_keys( $user_email, $phone_number ) { - $keys = array(); - foreach ( self::mosp_collect_puzzle_verification_identifiers( $user_email, $phone_number ) as $id ) { - $keys[] = self::mosp_build_puzzle_verification_key_string( $id ); - } - return array_values( array_unique( array_filter( $keys ) ) ); - } - - /** - * Verify puzzle completion through secure server-side validation - * - * This method replaces the vulnerable $_POST['mo_osp_puzzle_processed'] check - * with proper server-side validation using nonces and session data. - * - * @param string $user_email Email address. - * @param string $phone_number Phone number. - * @return bool True if puzzle verification is valid and recent. - */ - public static function mosp_is_puzzle_verification_valid( $user_email, $phone_number ) { - $keys = self::mosp_get_puzzle_verification_keys( $user_email, $phone_number ); - - foreach ( $keys as $verification_key ) { - if ( '' === $verification_key ) { - continue; - } - $verification_time = MoPHPSessions::get_session_var( $verification_key ); - if ( $verification_time && ( time() - $verification_time ) <= 300 ) { - $used_key = $verification_key . '_used'; - if ( MoPHPSessions::get_session_var( $used_key ) ) { - continue; - } - - MoPHPSessions::add_session_var( $used_key, time() ); - MoPHPSessions::unset_session( $verification_key ); - return true; - } - } - - $posted_verified = isset( $_POST['puzzle_verified'] ) ? sanitize_text_field( wp_unslash( $_POST['puzzle_verified'] ) ) : ''; - $posted_nonce = isset( $_POST['mo_osp_puzzle_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['mo_osp_puzzle_nonce'] ) ) : ''; - $posted_token = isset( $_POST['verification_token'] ) ? sanitize_text_field( wp_unslash( $_POST['verification_token'] ) ) : ''; - - if ( 'true' === $posted_verified && ! empty( $posted_nonce ) && ! empty( $posted_token ) ) { - if ( wp_verify_nonce( $posted_nonce, 'mo_osp_puzzle_verify' ) ) { - foreach ( $keys as $verification_key ) { - if ( '' === $verification_key || ! hash_equals( $verification_key, $posted_token ) ) { - continue; - } - $used_key = $verification_key . '_used'; - if ( ! MoPHPSessions::get_session_var( $used_key ) ) { - MoPHPSessions::add_session_var( $used_key, time() ); - return true; - } - } - } - } - - return false; - } - - /** - * Mark puzzle verification as complete with secure server-side storage. - * - * @param string $user_email Email address. - * @param string $phone_number Phone number. - */ - public static function mosp_mark_puzzle_verification_complete( $user_email, $phone_number ) { - $verification_key = self::mosp_get_puzzle_verification_key( $user_email, $phone_number ); - - MoPHPSessions::add_session_var( $verification_key, time() ); - } - - /** - * Get unique puzzle verification key for user. - * - * @param string $user_email Email address. - * @param string $phone_number Phone number. - * @return string Unique verification key. - */ - public static function mosp_get_puzzle_verification_key( $user_email, $phone_number ) { - if ( ! empty( $user_email ) ) { - $id = self::mosp_identifier_for_puzzle_key( $user_email, true ); - } else { - $id = self::mosp_identifier_for_puzzle_key( $phone_number, false ); - } - if ( '' === $id ) { - return ''; - } - return self::mosp_build_puzzle_verification_key_string( $id ); - } - - - /** - * Generate secure puzzle verification token. - * - * @param string $email Email address. - * @param string $phone Phone number. - * @return string Verification token. - */ - public static function mosp_generate_puzzle_verification_token( $email, $phone ) { - $timestamp = time(); - $verification_key = self::mosp_get_puzzle_verification_key( $email, $phone ); - if ( '' === $verification_key ) { - return ''; - } - - MoPHPSessions::add_session_var( $verification_key, $timestamp ); - - return $verification_key; - } - - /** - * Get client IP address with comprehensive header checking. - * - * This is the centralized IP detection method used across all helpers. - * - * @return string Client IP address. - */ - public static function mosp_get_client_ip() { - $ip_headers = array( - 'HTTP_CF_CONNECTING_IP', // Cloudflare. - 'HTTP_CLIENT_IP', // Proxy. - 'HTTP_X_FORWARDED_FOR', // Load balancer/proxy. - 'HTTP_X_FORWARDED', // Proxy. - 'HTTP_X_CLUSTER_CLIENT_IP', // Cluster. - 'HTTP_FORWARDED_FOR', // Proxy. - 'HTTP_FORWARDED', // Proxy. - 'REMOTE_ADDR', // Standard. - ); - - foreach ( $ip_headers as $header ) { - if ( ! empty( $_SERVER[ $header ] ) ) { - $ip = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ); - if ( strpos( $ip, ',' ) !== false ) { - $ip = trim( explode( ',', $ip )[0] ); - } - if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { - return $ip; - } - } - } - - return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; - } - - /** - * Get client User-Agent string. - * - * This is the centralized User-Agent detection method used across all helpers. - * - * @return string Client User-Agent string. - */ - public static function mosp_get_user_agent() { - return isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; - } - - /** - * Log security events with standardized format. - * - * Note: Callers should sanitize context data before passing to avoid logging sensitive information. - * - * @param string $event_type The type of security event. - * @param string $message The security message. - * @param array $context Additional context data (should be sanitized by caller). - */ - public static function mosp_log_security_event( $event_type, $message, $context = array() ) { - $log_message = 'MO_OSP: SECURITY - ' . strtoupper( $event_type ) . ' - ' . $message; - - if ( ! empty( $context ) ) { - $log_message .= ' - Context: ' . wp_json_encode( $context ); - } - - $log_message = apply_filters( 'mo_osp_security_log_message', $log_message, $event_type, $message, $context ); - } - } -} +<?php +/** + * Security Helper Class + * + * Contains all security-related validation and verification functions. + * This class handles nonce verification, token management, and security checks. + * + * @package otpspampreventer/helper + */ + +namespace OSP\Helper; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +use OTP\Helper\MoPHPSessions; +use OTP\Helper\MoUtility; + +if ( ! class_exists( 'MoSecurityHelper' ) ) { + /** + * Security Helper Class + * + * Handles all security-related operations including nonce verification, + * token generation, verification token management, and security validation. + */ + class MoSecurityHelper { + + /** + * Counting window for spam detection (15 minutes in seconds) + * + * This constant defines the time window used for counting attempts + * in spam detection algorithms. Change this value in one place to + * modify the counting window across the entire addon. + */ + const COUNTING_WINDOW_SECONDS = 900; // 15 minutes (15 * 60) + + /** + * Normalize email or phone the same way as puzzle verify / OTP send paths. + * + * @param string $value Raw value. + * @param bool $is_email Whether this value is an email field. + * @return string + */ + private static function mosp_identifier_for_puzzle_key( $value, $is_email ) { + $value = trim( (string) $value ); + if ( '' === $value ) { + return ''; + } + if ( $is_email ) { + return sanitize_email( $value ); + } + return MoUtility::process_phone_number( $value ); + } + + /** + * All user-identifier strings that may have been used to build the puzzle token + * (WooCommerce phone checkout clears email in the spam hook but puzzle AJAX often sends both). + * + * @param string $user_email Email from current request context. + * @param string $phone_number Phone from current request context. + * @return string[] Unique normalized identifiers. + */ + private static function mosp_collect_puzzle_verification_identifiers( $user_email, $phone_number ) { + $pairs = array( + array( $user_email, true ), + array( $phone_number, false ), + array( MoPHPSessions::get_session_var( 'user_email' ), true ), + array( MoPHPSessions::get_session_var( 'phone_number_mo' ), false ), + ); + $ids = array(); + foreach ( $pairs as $pair ) { + $n = self::mosp_identifier_for_puzzle_key( $pair[0], $pair[1] ); + if ( '' !== $n ) { + $ids[] = $n; + } + } + return array_values( array_unique( $ids ) ); + } + + /** + * Build session/token key string for one identifier (must match generate path). + * + * @param string $user_identifier Normalized email or phone. + * @return string + */ + private static function mosp_build_puzzle_verification_key_string( $user_identifier ) { + $session_id = session_id() ? session_id() : wp_get_session_token(); + $ip = self::mosp_get_client_ip(); + return 'mo_osp_puzzle_verified_' . md5( $user_identifier . $session_id . $ip ); + } + + /** + * Every puzzle verification key to try for this request (email-only, phone-only, session fallbacks). + * + * @param string $user_email Email address. + * @param string $phone_number Phone number. + * @return string[] + */ + public static function mosp_get_puzzle_verification_keys( $user_email, $phone_number ) { + $keys = array(); + foreach ( self::mosp_collect_puzzle_verification_identifiers( $user_email, $phone_number ) as $id ) { + $keys[] = self::mosp_build_puzzle_verification_key_string( $id ); + } + return array_values( array_unique( array_filter( $keys ) ) ); + } + + /** + * Verify puzzle completion through secure server-side validation + * + * This method replaces the vulnerable $_POST['mo_osp_puzzle_processed'] check + * with proper server-side validation using nonces and session data. + * + * @param string $user_email Email address. + * @param string $phone_number Phone number. + * @return bool True if puzzle verification is valid and recent. + */ + public static function mosp_is_puzzle_verification_valid( $user_email, $phone_number ) { + $keys = self::mosp_get_puzzle_verification_keys( $user_email, $phone_number ); + + foreach ( $keys as $verification_key ) { + if ( '' === $verification_key ) { + continue; + } + $verification_time = MoPHPSessions::get_session_var( $verification_key ); + if ( $verification_time && ( time() - $verification_time ) <= 300 ) { + $used_key = $verification_key . '_used'; + if ( MoPHPSessions::get_session_var( $used_key ) ) { + continue; + } + + MoPHPSessions::add_session_var( $used_key, time() ); + MoPHPSessions::unset_session( $verification_key ); + return true; + } + } + + $posted_verified = isset( $_POST['puzzle_verified'] ) ? sanitize_text_field( wp_unslash( $_POST['puzzle_verified'] ) ) : ''; + $posted_nonce = isset( $_POST['mo_osp_puzzle_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['mo_osp_puzzle_nonce'] ) ) : ''; + $posted_token = isset( $_POST['verification_token'] ) ? sanitize_text_field( wp_unslash( $_POST['verification_token'] ) ) : ''; + + if ( 'true' === $posted_verified && ! empty( $posted_nonce ) && ! empty( $posted_token ) ) { + if ( wp_verify_nonce( $posted_nonce, 'mo_osp_puzzle_verify' ) ) { + foreach ( $keys as $verification_key ) { + if ( '' === $verification_key || ! hash_equals( $verification_key, $posted_token ) ) { + continue; + } + $used_key = $verification_key . '_used'; + if ( ! MoPHPSessions::get_session_var( $used_key ) ) { + MoPHPSessions::add_session_var( $used_key, time() ); + return true; + } + } + } + } + + return false; + } + + /** + * Mark puzzle verification as complete with secure server-side storage. + * + * @param string $user_email Email address. + * @param string $phone_number Phone number. + */ + public static function mosp_mark_puzzle_verification_complete( $user_email, $phone_number ) { + $verification_key = self::mosp_get_puzzle_verification_key( $user_email, $phone_number ); + + MoPHPSessions::add_session_var( $verification_key, time() ); + } + + /** + * Get unique puzzle verification key for user. + * + * @param string $user_email Email address. + * @param string $phone_number Phone number. + * @return string Unique verification key. + */ + public static function mosp_get_puzzle_verification_key( $user_email, $phone_number ) { + if ( ! empty( $user_email ) ) { + $id = self::mosp_identifier_for_puzzle_key( $user_email, true ); + } else { + $id = self::mosp_identifier_for_puzzle_key( $phone_number, false ); + } + if ( '' === $id ) { + return ''; + } + return self::mosp_build_puzzle_verification_key_string( $id ); + } + + + /** + * Generate secure puzzle verification token. + * + * @param string $email Email address. + * @param string $phone Phone number. + * @return string Verification token. + */ + public static function mosp_generate_puzzle_verification_token( $email, $phone ) { + $timestamp = time(); + $verification_key = self::mosp_get_puzzle_verification_key( $email, $phone ); + if ( '' === $verification_key ) { + return ''; + } + + MoPHPSessions::add_session_var( $verification_key, $timestamp ); + + return $verification_key; + } + + /** + * Get client IP address with comprehensive header checking. + * + * This is the centralized IP detection method used across all helpers. + * + * @return string Client IP address. + */ + public static function mosp_get_client_ip() { + $ip_headers = array( + 'HTTP_CF_CONNECTING_IP', // Cloudflare. + 'HTTP_CLIENT_IP', // Proxy. + 'HTTP_X_FORWARDED_FOR', // Load balancer/proxy. + 'HTTP_X_FORWARDED', // Proxy. + 'HTTP_X_CLUSTER_CLIENT_IP', // Cluster. + 'HTTP_FORWARDED_FOR', // Proxy. + 'HTTP_FORWARDED', // Proxy. + 'REMOTE_ADDR', // Standard. + ); + + foreach ( $ip_headers as $header ) { + if ( ! empty( $_SERVER[ $header ] ) ) { + $ip = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ); + if ( strpos( $ip, ',' ) !== false ) { + $ip = trim( explode( ',', $ip )[0] ); + } + if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { + return $ip; + } + } + } + + return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; + } + + /** + * Get client User-Agent string. + * + * This is the centralized User-Agent detection method used across all helpers. + * + * @return string Client User-Agent string. + */ + public static function mosp_get_user_agent() { + return isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; + } + + /** + * Log security events with standardized format. + * + * Note: Callers should sanitize context data before passing to avoid logging sensitive information. + * + * @param string $event_type The type of security event. + * @param string $message The security message. + * @param array $context Additional context data (should be sanitized by caller). + */ + public static function mosp_log_security_event( $event_type, $message, $context = array() ) { + $log_message = 'MO_OSP: SECURITY - ' . strtoupper( $event_type ) . ' - ' . $message; + + if ( ! empty( $context ) ) { + $log_message .= ' - Context: ' . wp_json_encode( $context ); + } + + $log_message = apply_filters( 'mo_osp_security_log_message', $log_message, $event_type, $message, $context ); + } + } +} @@ -1,855 +1,855 @@ -/* OTP Spam Preventer Admin Styles */ -/* Professional spacing and layout */ - -/* Main Container */ -.mo-osp-container { - max-width: 1200px; - margin: 0 auto; -} - -/* Using plugin's existing mo-header classes - no custom header CSS needed */ -/* Addon toggle */ -.mo-osp-addon-toggle { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 16px; - color: rgb(51 65 85); -} - -.mo-osp-addon-toggle input { - margin: 0; -} - -/* Toggle placement above basic settings */ -.mo-osp-addon-toggle-row { - display: flex; - align-items: center; - justify-content: flex-start; - gap: 12px; - padding: 0 20px 12px 20px; -} - -.mo-osp-addon-toggle-emphasis { - padding: 2px 0; - font-weight: 700; - color: rgb(30 64 175); -} - -.mo-osp-addon-toggle-emphasis input { - transform: scale(1.2); -} - -/* SVG Icon Styles */ -.mo-osp-header-icon { - margin-right: 8px; - color: rgb(51 65 85); - vertical-align: middle; -} - -.mo-osp-section-icon { - margin-right: 8px; - color: rgb(51 65 85); - vertical-align: middle; -} - -.mo-osp-subsection-icon { - margin-right: 6px; - color: rgb(51 65 85); - vertical-align: middle; -} - -.mo-osp-field-icon { - margin-right: 6px; - color: rgb(100 116 139); - vertical-align: middle; -} - -/* Icon alignment in titles and labels */ -.mo-osp-section-title, -.mo-osp-subsection-title { - display: flex; - align-items: center; -} - - -.mo-input-label { - display: flex !important; - align-items: center; -} - -/* Card Layout */ -.mo-osp-card { - background: rgb(255 255 255); - border: 1px solid rgb(226 232 240); - border-radius: 8px; - margin-bottom: 24px; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); - overflow: hidden; - margin: 10px 20px; -} - -.mo-osp-card:last-child { - margin-bottom: 32px; /* Add bottom margin to advanced settings card */ -} - -/* Card Header */ -.mo-osp-card-header { - padding: 24px 24px 16px 24px; - border-bottom: 1px solid rgb(241 245 249); -} - -.mo-osp-section-title { - font-size: 18px; - font-weight: 600; - color: rgb(51 65 85); - margin: 0 0 8px 0; - line-height: 1.4; -} - -.mo-osp-section-desc { - font-size: 14px; - color: rgb(100 116 139); - margin: 0; - line-height: 1.5; -} - -/* Card Body */ -.mo-osp-card-body { - padding: 20px 24px 24px 24px; -} - -/* Fields Grid */ -.mo-osp-fields-grid { - display: grid; - grid-template-columns: 1fr; - gap: 24px; -} - -@media (min-width: 768px) { - .mo-osp-fields-grid { - grid-template-columns: 1fr 1fr; - gap: 32px 24px; - } -} - -/* Field Groups */ -.mo-osp-field-group { - display: flex; - flex-direction: column; - gap: 8px; -} - -.mo-osp-field-full { - grid-column: 1 / -1; -} - -.mo-osp-field-desc { - font-size: 13px; - color: rgb(100 116 139); - margin: 0; - line-height: 1.4; -} - -/* Toggle Button */ -.mo-osp-toggle-btn { - width: 100%; - padding: 20px 24px; - background: transparent; - border: none; - text-align: left; - cursor: pointer; - transition: background-color 0.15s ease; -} - -.mo-osp-toggle-btn:hover { - background-color: rgb(248 250 252); -} - -.mo-osp-toggle-content { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.mo-osp-toggle-indicator { - display: flex; - align-items: center; - gap: 8px; -} - -.mo-osp-toggle-text { - font-size: 14px; - color: rgb(100 116 139); - font-weight: 500; -} - -.mo-osp-toggle-icon { - font-size: 12px; - color: rgb(100 116 139); - transition: transform 0.2s ease; - display: inline-block; -} - -.mo-osp-toggle-icon.rotate-180 { - transform: rotate(180deg); -} - -/* Advanced Settings */ -.mo-osp-advanced-hidden { - display: none; -} - -.mo-osp-advanced-visible { - display: block; -} - -.mo-osp-advanced-content { - padding: 0 24px 32px 24px; /* Increased bottom padding */ - border-top: 1px solid rgb(241 245 249); -} - -/* Subsections */ -.mo-osp-subsection { - margin-top: 32px; -} - -.mo-osp-subsection:last-child { - margin-bottom: 0; -} - -.mo-osp-subsection-header { - margin-bottom: 20px; - padding-top: 24px; -} - -.mo-osp-subsection:first-child .mo-osp-subsection-header { - padding-top: 0; -} - -.mo-osp-subsection-title { - font-size: 16px; - font-weight: 600; - color: rgb(51 65 85); - margin: 0 0 6px 0; - line-height: 1.4; -} - -/* Form Input Overrides */ -.mo-osp-container .mo-form-input, -.mo-osp-container .mo-form-textarea { - border-radius: 6px; - border: 1px solid rgb(226 232 240); - padding: 10px 12px; - font-size: 14px; - transition: border-color 0.15s ease, box-shadow 0.15s ease; - background: rgb(255 255 255); - color: rgb(51 65 85); -} - -.mo-osp-container .mo-form-input:focus, -.mo-osp-container .mo-form-textarea:focus { - border-color: rgb(99 102 241); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); - outline: none; -} - -.mo-osp-container .mo-input-label { - font-size: 14px; - font-weight: 500; - color: rgb(51 65 85); - margin-bottom: 6px; - display: block; -} - -.mo-osp-container .mo-form-textarea { - resize: vertical; - min-height: 120px; -} - -/* Number Input Styling */ -.mo-osp-container .mo-form-input[type="number"] { - -webkit-appearance: none; - -moz-appearance: textfield; -} - -.mo-osp-container .mo-form-input[type="number"]::-webkit-outer-spin-button, -.mo-osp-container .mo-form-input[type="number"]::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - -/* Validation Error Styling */ -.mo-osp-error-field { - border-color: #ef4444 !important; - box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1) !important; -} - -.mo-osp-validation-error { - color: #ef4444; - font-size: 12px; - margin-top: 4px; - display: block; -} - -/* Responsive Design */ -@media (max-width: 767px) { - .mo-osp-container { - padding: 0 16px; - } - - .mo-osp-card-header, - .mo-osp-card-body, - .mo-osp-advanced-content { - padding-left: 20px; - padding-right: 20px; - } - - .mo-osp-toggle-btn { - padding-left: 20px; - padding-right: 20px; - } - - .mo-osp-section-title { - font-size: 16px; - } - - .mo-osp-subsection-title { - font-size: 15px; - } -} - -@media (max-width: 480px) { - .mo-osp-container { - padding: 0 12px; - } - - .mo-osp-card-header, - .mo-osp-card-body, - .mo-osp-advanced-content { - padding-left: 16px; - padding-right: 16px; - } - - .mo-osp-toggle-btn { - padding-left: 16px; - padding-right: 16px; - } -} - -/* Puzzle Verification Popup Styles */ -.mo-osp-puzzle-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(4px); - z-index: 100001 !important; /* Higher than WooCommerce checkout popup (100000) and other modals */ - display: flex; - align-items: center; - justify-content: center; - animation: mo-osp-fade-in 0.3s ease-out; -} - -.mo-osp-puzzle-overlay.mo-osp-hidden { - display: none; -} - -/* Puzzle popup outer wrapper - ensures it appears above WooCommerce checkout popup */ -#mo-osp-puzzle-popup-outer-div { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 100001 !important; /* Higher than WooCommerce checkout popup (100000) */ - display: none; -} - -.mo-osp-puzzle-popup { - background: rgb(255 255 255); - border-radius: 12px; - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); - max-width: 480px; - width: 90%; - max-height: 90vh; - overflow: hidden; - animation: mo-osp-slide-up 0.3s ease-out; - position: relative; - z-index: 100002; /* Higher than overlay to ensure popup content is on top */ -} - -.mo-osp-puzzle-header { - padding: 24px 24px 16px 24px; - border-bottom: 1px solid rgb(241 245 249); - display: flex; - align-items: center; - justify-content: space-between; -} - -.mo-osp-puzzle-title { - display: flex; - align-items: center; - margin: 0; - font-size: 18px; - font-weight: 600; - color: rgb(51 65 85); -} - -.mo-osp-puzzle-icon { - margin-right: 8px; - color: rgb(99 102 241); -} - -.mo-osp-puzzle-close { - background: none; - border: none; - padding: 8px; - border-radius: 6px; - cursor: pointer; - color: rgb(107 114 128); - transition: all 0.2s ease; -} - -.mo-osp-puzzle-close:hover { - background: rgb(243 244 246); - color: rgb(75 85 99); -} - -.mo-osp-puzzle-body { - padding: 24px; -} - -.mo-osp-puzzle-message { - margin-bottom: 24px; -} - -.mo-osp-puzzle-message p { - margin: 0; - color: rgb(75 85 99); - font-size: 14px; - line-height: 1.5; -} - -.mo-osp-puzzle-question { - margin-bottom: 20px; -} - -.mo-osp-puzzle-equation { - display: flex; - align-items: center; - justify-content: center; - gap: 16px; - padding: 16px; - background: rgb(248 250 252); - border: 2px solid rgb(226 232 240); - border-radius: 8px; - font-size: 24px; - font-weight: 600; -} - -.mo-osp-equation-text { - color: rgb(51 65 85); - font-family: 'Courier New', monospace; -} - -.mo-osp-puzzle-image { - max-width: 280px; - width: 100%; - height: auto; - border-radius: 4px; - border: 2px solid rgb(226 232 240); - background: rgb(255 255 255); - padding: 2px; - display: block; -} - -.mo-osp-equals { - color: rgb(99 102 241); - font-weight: 700; -} - -.mo-osp-puzzle-input { - width: 80px; - height: 48px; - border: 2px solid rgb(226 232 240); - border-radius: 6px; - text-align: center; - font-size: 20px; - font-weight: 600; - color: rgb(51 65 85); - background: rgb(255 255 255); - transition: border-color 0.2s ease, box-shadow 0.2s ease; -} - -.mo-osp-puzzle-input:focus { - outline: none; - border-color: rgb(99 102 241); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); -} - -.mo-osp-puzzle-input::placeholder { - color: rgb(156 163 175); -} - -.mo-osp-puzzle-error { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 16px; - background: rgb(254 242 242); - border: 1px solid rgb(252 165 165); - border-radius: 6px; - color: rgb(220 38 38); - font-size: 14px; - margin-bottom: 20px; -} - -.mo-osp-error-icon { - flex-shrink: 0; -} - -.mo-osp-puzzle-footer { - padding: 16px 24px 24px 24px; - display: flex; - gap: 12px; - justify-content: flex-end; -} - -.mo-osp-puzzle-btn { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 16px; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - border: none; -} - -.mo-osp-btn-secondary { - background: rgb(243 244 246); - color: rgb(75 85 99); - border: 1px solid rgb(209 213 219); -} - -.mo-osp-btn-secondary:hover { - background: rgb(229 231 235); - color: rgb(55 65 81); -} - -.mo-osp-btn-primary { - background: rgb(99 102 241); - color: rgb(255 255 255); -} - -.mo-osp-btn-primary:hover { - background: rgb(79 70 229); -} - -.mo-osp-btn-primary:disabled { - background: rgb(156 163 175); - cursor: not-allowed; -} - -/* Animations */ -@keyframes mo-osp-fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes mo-osp-slide-up { - from { - opacity: 0; - transform: translateY(20px) scale(0.95); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -/* Responsive design for puzzle popup */ -@media (max-width: 480px) { - .mo-osp-puzzle-popup { - width: 95%; - margin: 20px; - } - - .mo-osp-puzzle-header, - .mo-osp-puzzle-body, - .mo-osp-puzzle-footer { - padding-left: 20px; - padding-right: 20px; - } - - .mo-osp-puzzle-equation { - font-size: 20px; - gap: 12px; - padding: 12px; - flex-wrap: wrap; - } - - .mo-osp-puzzle-image { - max-width: 100%; - } - - .mo-osp-puzzle-input { - width: 70px; - height: 44px; - font-size: 18px; - } - - .mo-osp-puzzle-footer { - flex-direction: column; - } - - .mo-osp-puzzle-btn { - width: 100%; - justify-content: center; - } -} - -/* Light theme only - no dark mode */ - -/* Send OTP Button Processing State */ -.mo-osp-processing { - opacity: 0.7 !important; - cursor: not-allowed !important; - position: relative; -} - -.mo-osp-processing::after { - content: ''; - position: absolute; - top: 50%; - left: 50%; - width: 16px; - height: 16px; - margin: -8px 0 0 -8px; - border: 2px solid #ffffff; - border-radius: 50%; - border-top-color: transparent; - animation: mo-osp-spin 1s linear infinite; - z-index: 1; -} - -@keyframes mo-osp-spin { - to { - transform: rotate(360deg); - } -} - -/* Ensure button text is still visible during processing */ -.mo-osp-processing { - color: rgba(255, 255, 255, 0.8) !important; -} - -/* Blocked Users Table Styles */ -.mo-osp-blocked-users-table { - width: 100%; - border-collapse: collapse; - margin-top: 16px; - background: rgb(255 255 255); - border-radius: 8px; - overflow: hidden; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); -} - -.mo-osp-blocked-users-table thead { - background: rgb(249 250 251); - border-bottom: 2px solid rgb(229 231 235); -} - -.mo-osp-blocked-users-table th { - padding: 12px 16px; - text-align: left; - font-weight: 600; - font-size: 14px; - color: rgb(55 65 81); - border-bottom: 1px solid rgb(229 231 235); -} - -.mo-osp-blocked-users-table td { - padding: 12px 16px; - border-bottom: 1px solid rgb(243 244 246); - font-size: 14px; - color: rgb(75 85 99); -} - -.mo-osp-blocked-users-table tbody tr:hover { - background: rgb(249 250 251); -} - -.mo-osp-blocked-users-table tbody tr.mo-osp-expired { - opacity: 0.6; -} - -.mo-osp-blocked-users-table .mo-osp-no-data, -.mo-osp-blocked-users-table .mo-osp-error { - text-align: center; - padding: 24px; - color: rgb(107 114 128); - font-style: italic; -} - -.mo-osp-blocked-users-table .mo-osp-error { - color: rgb(220 38 38); -} - -.mo-osp-identifier-type { - display: inline-block; - padding: 2px 8px; - background: rgb(243 244 246); - color: rgb(75 85 99); - border-radius: 4px; - font-size: 12px; - font-weight: 500; - margin-right: 8px; - text-transform: capitalize; -} - -.mo-osp-identifier-masked { - font-family: monospace; - color: rgb(55 65 81); -} - -.mo-osp-block-reason { - display: inline-block; - padding: 4px 10px; - background: rgb(254 242 242); - color: rgb(185 28 28); - border-radius: 4px; - font-size: 12px; - font-weight: 500; -} - -.mo-osp-remaining-time { - font-weight: 600; - color: rgb(220 38 38); - font-family: monospace; -} - -.mo-osp-blocked-users-actions { - margin-top: 16px; - display: flex; - justify-content: flex-end; - gap: 12px; -} - -.mo-osp-blocked-users-pagination { - margin-top: 16px; - display: flex; - justify-content: center; - align-items: center; - gap: 16px; - padding: 12px 0; -} - -.mo-osp-blocked-users-pagination button { - min-width: 100px; -} - -.mo-osp-blocked-users-pagination button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.mo-osp-loading { - text-align: center; - padding: 24px; - color: rgb(107 114 128); -} - -.mo-button-small { - padding: 6px 12px; - font-size: 13px; - min-height: auto; -} - -.mo-button-small:hover { - transform: none; -} - -/* Unblock User Button Styling */ -.mo-osp-unblock-user { - background: rgb(220 38 38) !important; - color: rgb(255 255 255) !important; - border: 1px solid rgb(185 28 28) !important; - border-radius: 6px !important; - padding: 8px 16px !important; - font-size: 13px !important; - font-weight: 500 !important; - cursor: pointer !important; - transition: all 0.2s ease !important; - display: inline-flex !important; - align-items: center !important; - gap: 6px !important; - min-width: auto !important; - text-decoration: none !important; - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important; -} - -.mo-osp-unblock-user:hover { - background: rgb(185 28 28) !important; - border-color: rgb(153 27 27) !important; - box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.1) !important; - transform: translateY(-1px) !important; -} - -.mo-osp-unblock-user:active { - transform: translateY(0) !important; - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important; -} - -.mo-osp-unblock-user:disabled, -.mo-osp-unblock-user[disabled] { - background: rgb(156 163 175) !important; - border-color: rgb(156 163 175) !important; - color: rgb(255 255 255) !important; - cursor: not-allowed !important; - opacity: 0.6 !important; - transform: none !important; -} - -.mo-osp-unblock-user:disabled:hover, -.mo-osp-unblock-user[disabled]:hover { - background: rgb(156 163 175) !important; - border-color: rgb(156 163 175) !important; - transform: none !important; - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important; -} - -/* Responsive table */ -@media (max-width: 768px) { - .mo-osp-blocked-users-table-container { - overflow-x: auto; - } - - .mo-osp-blocked-users-table { - min-width: 600px; - } - - .mo-osp-blocked-users-table th, - .mo-osp-blocked-users-table td { - padding: 10px 12px; - font-size: 13px; - } - - .mo-osp-blocked-users-actions { - flex-direction: column; - } - - .mo-osp-blocked-users-actions button { - width: 100%; - } +/* OTP Spam Preventer Admin Styles */ +/* Professional spacing and layout */ + +/* Main Container */ +.mo-osp-container { + max-width: 1200px; + margin: 0 auto; +} + +/* Using plugin's existing mo-header classes - no custom header CSS needed */ +/* Addon toggle */ +.mo-osp-addon-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 16px; + color: rgb(51 65 85); +} + +.mo-osp-addon-toggle input { + margin: 0; +} + +/* Toggle placement above basic settings */ +.mo-osp-addon-toggle-row { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + padding: 0 20px 12px 20px; +} + +.mo-osp-addon-toggle-emphasis { + padding: 2px 0; + font-weight: 700; + color: rgb(30 64 175); +} + +.mo-osp-addon-toggle-emphasis input { + transform: scale(1.2); +} + +/* SVG Icon Styles */ +.mo-osp-header-icon { + margin-right: 8px; + color: rgb(51 65 85); + vertical-align: middle; +} + +.mo-osp-section-icon { + margin-right: 8px; + color: rgb(51 65 85); + vertical-align: middle; +} + +.mo-osp-subsection-icon { + margin-right: 6px; + color: rgb(51 65 85); + vertical-align: middle; +} + +.mo-osp-field-icon { + margin-right: 6px; + color: rgb(100 116 139); + vertical-align: middle; +} + +/* Icon alignment in titles and labels */ +.mo-osp-section-title, +.mo-osp-subsection-title { + display: flex; + align-items: center; +} + + +.mo-input-label { + display: flex !important; + align-items: center; +} + +/* Card Layout */ +.mo-osp-card { + background: rgb(255 255 255); + border: 1px solid rgb(226 232 240); + border-radius: 8px; + margin-bottom: 24px; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + overflow: hidden; + margin: 10px 20px; +} + +.mo-osp-card:last-child { + margin-bottom: 32px; /* Add bottom margin to advanced settings card */ +} + +/* Card Header */ +.mo-osp-card-header { + padding: 24px 24px 16px 24px; + border-bottom: 1px solid rgb(241 245 249); +} + +.mo-osp-section-title { + font-size: 18px; + font-weight: 600; + color: rgb(51 65 85); + margin: 0 0 8px 0; + line-height: 1.4; +} + +.mo-osp-section-desc { + font-size: 14px; + color: rgb(100 116 139); + margin: 0; + line-height: 1.5; +} + +/* Card Body */ +.mo-osp-card-body { + padding: 20px 24px 24px 24px; +} + +/* Fields Grid */ +.mo-osp-fields-grid { + display: grid; + grid-template-columns: 1fr; + gap: 24px; +} + +@media (min-width: 768px) { + .mo-osp-fields-grid { + grid-template-columns: 1fr 1fr; + gap: 32px 24px; + } +} + +/* Field Groups */ +.mo-osp-field-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.mo-osp-field-full { + grid-column: 1 / -1; +} + +.mo-osp-field-desc { + font-size: 13px; + color: rgb(100 116 139); + margin: 0; + line-height: 1.4; +} + +/* Toggle Button */ +.mo-osp-toggle-btn { + width: 100%; + padding: 20px 24px; + background: transparent; + border: none; + text-align: left; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.mo-osp-toggle-btn:hover { + background-color: rgb(248 250 252); +} + +.mo-osp-toggle-content { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.mo-osp-toggle-indicator { + display: flex; + align-items: center; + gap: 8px; +} + +.mo-osp-toggle-text { + font-size: 14px; + color: rgb(100 116 139); + font-weight: 500; +} + +.mo-osp-toggle-icon { + font-size: 12px; + color: rgb(100 116 139); + transition: transform 0.2s ease; + display: inline-block; +} + +.mo-osp-toggle-icon.rotate-180 { + transform: rotate(180deg); +} + +/* Advanced Settings */ +.mo-osp-advanced-hidden { + display: none; +} + +.mo-osp-advanced-visible { + display: block; +} + +.mo-osp-advanced-content { + padding: 0 24px 32px 24px; /* Increased bottom padding */ + border-top: 1px solid rgb(241 245 249); +} + +/* Subsections */ +.mo-osp-subsection { + margin-top: 32px; +} + +.mo-osp-subsection:last-child { + margin-bottom: 0; +} + +.mo-osp-subsection-header { + margin-bottom: 20px; + padding-top: 24px; +} + +.mo-osp-subsection:first-child .mo-osp-subsection-header { + padding-top: 0; +} + +.mo-osp-subsection-title { + font-size: 16px; + font-weight: 600; + color: rgb(51 65 85); + margin: 0 0 6px 0; + line-height: 1.4; +} + +/* Form Input Overrides */ +.mo-osp-container .mo-form-input, +.mo-osp-container .mo-form-textarea { + border-radius: 6px; + border: 1px solid rgb(226 232 240); + padding: 10px 12px; + font-size: 14px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + background: rgb(255 255 255); + color: rgb(51 65 85); +} + +.mo-osp-container .mo-form-input:focus, +.mo-osp-container .mo-form-textarea:focus { + border-color: rgb(99 102 241); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); + outline: none; +} + +.mo-osp-container .mo-input-label { + font-size: 14px; + font-weight: 500; + color: rgb(51 65 85); + margin-bottom: 6px; + display: block; +} + +.mo-osp-container .mo-form-textarea { + resize: vertical; + min-height: 120px; +} + +/* Number Input Styling */ +.mo-osp-container .mo-form-input[type="number"] { + -webkit-appearance: none; + -moz-appearance: textfield; +} + +.mo-osp-container .mo-form-input[type="number"]::-webkit-outer-spin-button, +.mo-osp-container .mo-form-input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Validation Error Styling */ +.mo-osp-error-field { + border-color: #ef4444 !important; + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1) !important; +} + +.mo-osp-validation-error { + color: #ef4444; + font-size: 12px; + margin-top: 4px; + display: block; +} + +/* Responsive Design */ +@media (max-width: 767px) { + .mo-osp-container { + padding: 0 16px; + } + + .mo-osp-card-header, + .mo-osp-card-body, + .mo-osp-advanced-content { + padding-left: 20px; + padding-right: 20px; + } + + .mo-osp-toggle-btn { + padding-left: 20px; + padding-right: 20px; + } + + .mo-osp-section-title { + font-size: 16px; + } + + .mo-osp-subsection-title { + font-size: 15px; + } +} + +@media (max-width: 480px) { + .mo-osp-container { + padding: 0 12px; + } + + .mo-osp-card-header, + .mo-osp-card-body, + .mo-osp-advanced-content { + padding-left: 16px; + padding-right: 16px; + } + + .mo-osp-toggle-btn { + padding-left: 16px; + padding-right: 16px; + } +} + +/* Puzzle Verification Popup Styles */ +.mo-osp-puzzle-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + z-index: 100001 !important; /* Higher than WooCommerce checkout popup (100000) and other modals */ + display: flex; + align-items: center; + justify-content: center; + animation: mo-osp-fade-in 0.3s ease-out; +} + +.mo-osp-puzzle-overlay.mo-osp-hidden { + display: none; +} + +/* Puzzle popup outer wrapper - ensures it appears above WooCommerce checkout popup */ +#mo-osp-puzzle-popup-outer-div { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 100001 !important; /* Higher than WooCommerce checkout popup (100000) */ + display: none; +} + +.mo-osp-puzzle-popup { + background: rgb(255 255 255); + border-radius: 12px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + max-width: 480px; + width: 90%; + max-height: 90vh; + overflow: hidden; + animation: mo-osp-slide-up 0.3s ease-out; + position: relative; + z-index: 100002; /* Higher than overlay to ensure popup content is on top */ +} + +.mo-osp-puzzle-header { + padding: 24px 24px 16px 24px; + border-bottom: 1px solid rgb(241 245 249); + display: flex; + align-items: center; + justify-content: space-between; +} + +.mo-osp-puzzle-title { + display: flex; + align-items: center; + margin: 0; + font-size: 18px; + font-weight: 600; + color: rgb(51 65 85); +} + +.mo-osp-puzzle-icon { + margin-right: 8px; + color: rgb(99 102 241); +} + +.mo-osp-puzzle-close { + background: none; + border: none; + padding: 8px; + border-radius: 6px; + cursor: pointer; + color: rgb(107 114 128); + transition: all 0.2s ease; +} + +.mo-osp-puzzle-close:hover { + background: rgb(243 244 246); + color: rgb(75 85 99); +} + +.mo-osp-puzzle-body { + padding: 24px; +} + +.mo-osp-puzzle-message { + margin-bottom: 24px; +} + +.mo-osp-puzzle-message p { + margin: 0; + color: rgb(75 85 99); + font-size: 14px; + line-height: 1.5; +} + +.mo-osp-puzzle-question { + margin-bottom: 20px; +} + +.mo-osp-puzzle-equation { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + padding: 16px; + background: rgb(248 250 252); + border: 2px solid rgb(226 232 240); + border-radius: 8px; + font-size: 24px; + font-weight: 600; +} + +.mo-osp-equation-text { + color: rgb(51 65 85); + font-family: 'Courier New', monospace; +} + +.mo-osp-puzzle-image { + max-width: 280px; + width: 100%; + height: auto; + border-radius: 4px; + border: 2px solid rgb(226 232 240); + background: rgb(255 255 255); + padding: 2px; + display: block; +} + +.mo-osp-equals { + color: rgb(99 102 241); + font-weight: 700; +} + +.mo-osp-puzzle-input { + width: 80px; + height: 48px; + border: 2px solid rgb(226 232 240); + border-radius: 6px; + text-align: center; + font-size: 20px; + font-weight: 600; + color: rgb(51 65 85); + background: rgb(255 255 255); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.mo-osp-puzzle-input:focus { + outline: none; + border-color: rgb(99 102 241); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.mo-osp-puzzle-input::placeholder { + color: rgb(156 163 175); +} + +.mo-osp-puzzle-error { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: rgb(254 242 242); + border: 1px solid rgb(252 165 165); + border-radius: 6px; + color: rgb(220 38 38); + font-size: 14px; + margin-bottom: 20px; +} + +.mo-osp-error-icon { + flex-shrink: 0; +} + +.mo-osp-puzzle-footer { + padding: 16px 24px 24px 24px; + display: flex; + gap: 12px; + justify-content: flex-end; +} + +.mo-osp-puzzle-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: none; +} + +.mo-osp-btn-secondary { + background: rgb(243 244 246); + color: rgb(75 85 99); + border: 1px solid rgb(209 213 219); +} + +.mo-osp-btn-secondary:hover { + background: rgb(229 231 235); + color: rgb(55 65 81); +} + +.mo-osp-btn-primary { + background: rgb(99 102 241); + color: rgb(255 255 255); +} + +.mo-osp-btn-primary:hover { + background: rgb(79 70 229); +} + +.mo-osp-btn-primary:disabled { + background: rgb(156 163 175); + cursor: not-allowed; +} + +/* Animations */ +@keyframes mo-osp-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes mo-osp-slide-up { + from { + opacity: 0; + transform: translateY(20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Responsive design for puzzle popup */ +@media (max-width: 480px) { + .mo-osp-puzzle-popup { + width: 95%; + margin: 20px; + } + + .mo-osp-puzzle-header, + .mo-osp-puzzle-body, + .mo-osp-puzzle-footer { + padding-left: 20px; + padding-right: 20px; + } + + .mo-osp-puzzle-equation { + font-size: 20px; + gap: 12px; + padding: 12px; + flex-wrap: wrap; + } + + .mo-osp-puzzle-image { + max-width: 100%; + } + + .mo-osp-puzzle-input { + width: 70px; + height: 44px; + font-size: 18px; + } + + .mo-osp-puzzle-footer { + flex-direction: column; + } + + .mo-osp-puzzle-btn { + width: 100%; + justify-content: center; + } +} + +/* Light theme only - no dark mode */ + +/* Send OTP Button Processing State */ +.mo-osp-processing { + opacity: 0.7 !important; + cursor: not-allowed !important; + position: relative; +} + +.mo-osp-processing::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + border: 2px solid #ffffff; + border-radius: 50%; + border-top-color: transparent; + animation: mo-osp-spin 1s linear infinite; + z-index: 1; +} + +@keyframes mo-osp-spin { + to { + transform: rotate(360deg); + } +} + +/* Ensure button text is still visible during processing */ +.mo-osp-processing { + color: rgba(255, 255, 255, 0.8) !important; +} + +/* Blocked Users Table Styles */ +.mo-osp-blocked-users-table { + width: 100%; + border-collapse: collapse; + margin-top: 16px; + background: rgb(255 255 255); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); +} + +.mo-osp-blocked-users-table thead { + background: rgb(249 250 251); + border-bottom: 2px solid rgb(229 231 235); +} + +.mo-osp-blocked-users-table th { + padding: 12px 16px; + text-align: left; + font-weight: 600; + font-size: 14px; + color: rgb(55 65 81); + border-bottom: 1px solid rgb(229 231 235); +} + +.mo-osp-blocked-users-table td { + padding: 12px 16px; + border-bottom: 1px solid rgb(243 244 246); + font-size: 14px; + color: rgb(75 85 99); +} + +.mo-osp-blocked-users-table tbody tr:hover { + background: rgb(249 250 251); +} + +.mo-osp-blocked-users-table tbody tr.mo-osp-expired { + opacity: 0.6; +} + +.mo-osp-blocked-users-table .mo-osp-no-data, +.mo-osp-blocked-users-table .mo-osp-error { + text-align: center; + padding: 24px; + color: rgb(107 114 128); + font-style: italic; +} + +.mo-osp-blocked-users-table .mo-osp-error { + color: rgb(220 38 38); +} + +.mo-osp-identifier-type { + display: inline-block; + padding: 2px 8px; + background: rgb(243 244 246); + color: rgb(75 85 99); + border-radius: 4px; + font-size: 12px; + font-weight: 500; + margin-right: 8px; + text-transform: capitalize; +} + +.mo-osp-identifier-masked { + font-family: monospace; + color: rgb(55 65 81); +} + +.mo-osp-block-reason { + display: inline-block; + padding: 4px 10px; + background: rgb(254 242 242); + color: rgb(185 28 28); + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.mo-osp-remaining-time { + font-weight: 600; + color: rgb(220 38 38); + font-family: monospace; +} + +.mo-osp-blocked-users-actions { + margin-top: 16px; + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.mo-osp-blocked-users-pagination { + margin-top: 16px; + display: flex; + justify-content: center; + align-items: center; + gap: 16px; + padding: 12px 0; +} + +.mo-osp-blocked-users-pagination button { + min-width: 100px; +} + +.mo-osp-blocked-users-pagination button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.mo-osp-loading { + text-align: center; + padding: 24px; + color: rgb(107 114 128); +} + +.mo-button-small { + padding: 6px 12px; + font-size: 13px; + min-height: auto; +} + +.mo-button-small:hover { + transform: none; +} + +/* Unblock User Button Styling */ +.mo-osp-unblock-user { + background: rgb(220 38 38) !important; + color: rgb(255 255 255) !important; + border: 1px solid rgb(185 28 28) !important; + border-radius: 6px !important; + padding: 8px 16px !important; + font-size: 13px !important; + font-weight: 500 !important; + cursor: pointer !important; + transition: all 0.2s ease !important; + display: inline-flex !important; + align-items: center !important; + gap: 6px !important; + min-width: auto !important; + text-decoration: none !important; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important; +} + +.mo-osp-unblock-user:hover { + background: rgb(185 28 28) !important; + border-color: rgb(153 27 27) !important; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.1) !important; + transform: translateY(-1px) !important; +} + +.mo-osp-unblock-user:active { + transform: translateY(0) !important; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important; +} + +.mo-osp-unblock-user:disabled, +.mo-osp-unblock-user[disabled] { + background: rgb(156 163 175) !important; + border-color: rgb(156 163 175) !important; + color: rgb(255 255 255) !important; + cursor: not-allowed !important; + opacity: 0.6 !important; + transform: none !important; +} + +.mo-osp-unblock-user:disabled:hover, +.mo-osp-unblock-user[disabled]:hover { + background: rgb(156 163 175) !important; + border-color: rgb(156 163 175) !important; + transform: none !important; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important; +} + +/* Responsive table */ +@media (max-width: 768px) { + .mo-osp-blocked-users-table-container { + overflow-x: auto; + } + + .mo-osp-blocked-users-table { + min-width: 600px; + } + + .mo-osp-blocked-users-table th, + .mo-osp-blocked-users-table td { + padding: 10px 12px; + font-size: 13px; + } + + .mo-osp-blocked-users-actions { + flex-direction: column; + } + + .mo-osp-blocked-users-actions button { + width: 100%; + } } \ No newline at end of file @@ -1,1050 +1,1050 @@ -/** - * OTP Puzzle Verification System - * - * Secure puzzle system for human verification before OTP sending. - * Features: - * - Server-side puzzle generation and validation - * - Session-based security with fallback storage - * - Multiple detection methods for puzzle requirements - * - Comprehensive error handling and logging - * - * @package otpspampreventer - */ - -(function($mo) { - 'use strict'; - - // Puzzle Verification System - window.MO_OSP_Puzzle = { - // State variables - currentPuzzle: null, - currentAnswer: null, - lastUserAnswer: null, - pendingOtpData: null, - isShowing: false, - puzzleCheckCount: 0, - lastVerificationResponse: null, - isGenerating: false, // Flag to prevent duplicate generate calls - isVerifying: false, // Flag to prevent duplicate verify calls - eventsBound: false, // Flag to prevent duplicate event binding - - /** - * Initialize the puzzle system - */ - init: function() { - // Only bind events once to prevent duplicate handlers - if (!this.eventsBound) { - this.bindEvents(); - this.eventsBound = true; - } - this.puzzleCheckCount = 0; // Initialize counter to prevent infinite loops - }, - - /** - * Bind all puzzle-related event handlers - * CRITICAL: Unbind previous handlers first to prevent duplicate event binding - */ - bindEvents: function() { - var self = this; - - // CRITICAL FIX: Unbind all previous handlers to prevent duplicate event binding - $mo(document).off('click', '.mo-osp-puzzle-close'); - $mo(document).off('click', '.mo-osp-puzzle-popup'); - $mo(document).off('click', '#mo-osp-puzzle-refresh'); - $mo(document).off('click', '#mo-osp-puzzle-verify'); - $mo(document).off('keypress', '#mo-osp-puzzle-answer'); - $mo(document).off('input', '#mo-osp-puzzle-answer'); - - // Close puzzle popup - $mo(document).on('click', '.mo-osp-puzzle-close', function() { - self.closePuzzle(); - }); - - // Prevent closing when clicking inside popup - $mo(document).on('click', '.mo-osp-puzzle-popup', function(e) { - e.stopPropagation(); - }); - - // Refresh puzzle - $mo(document).on('click', '#mo-osp-puzzle-refresh', function() { - self.generatePuzzle(); - $mo('#mo-osp-puzzle-answer').val('').focus(); - }); - - // Verify puzzle - prevent duplicate calls - $mo(document).on('click', '#mo-osp-puzzle-verify', function(e) { - e.preventDefault(); - e.stopPropagation(); - - // Prevent duplicate calls - check if verification is already in progress - if (self.isVerifying) { - return false; - } - - self.verifyPuzzle(); - return false; - }); - - // Enter key in puzzle input - prevent duplicate calls - $mo(document).on('keypress', '#mo-osp-puzzle-answer', function(e) { - if (e.which === 13) { // Enter key - e.preventDefault(); - - // Prevent duplicate calls - if (self.isVerifying) { - return false; - } - - self.verifyPuzzle(); - return false; - } - }); - - // Clear error on input - $mo(document).on('input', '#mo-osp-puzzle-answer', function() { - self.hideError(); - }); - - // Note: checkForPuzzleMessage() is now only called when OTP request is made - // This prevents puzzle popup from showing on every page load - }, - - /** - * Check for puzzle requirement messages in DOM - * Uses circuit breaker to prevent infinite loops - */ - checkForPuzzleMessage: function() { - var self = this; - - // Prevent infinite loop - only check if puzzle is not already showing - if (self.isShowing) { - return; - } - - // Circuit breaker: Stop checking after 30 attempts (30 seconds) to prevent infinite loops - if (!this.puzzleCheckCount) { - this.puzzleCheckCount = 0; - } - this.puzzleCheckCount++; - - if (this.puzzleCheckCount > 30) { - return; - } - - - // Look for the puzzle message in various message containers - var messageSelectors = [ - '#mo_message', - '.mo_message', - '[id*="mo_message"]', - '.woocommerce-message', - '.notice', - '.alert', - '[class*="message"]', - '.mo-otp-message', - 'div[style*="color"]', // Catch styled message divs - '.error', - '.success', - '.info' - ]; - - var found = false; - var allMessages = []; - - // Specific puzzle message patterns (must be precise to avoid false positives) - var puzzlePatterns = [ - 'Please complete the security verification to continue', - 'solve this simple puzzle to verify you are human before sending an OTP', - 'complete the security verification to continue', - 'puzzle to verify you are human before sending', - 'security purposes, please solve this simple puzzle' - ]; - - for (var i = 0; i < messageSelectors.length && !found; i++) { - $mo(messageSelectors[i]).each(function() { - var messageText = $mo(this).text().trim(); - if (messageText) { - allMessages.push({ - selector: messageSelectors[i], - text: messageText, - element: this - }); - } - - // Check against multiple puzzle patterns - if (messageText && typeof messageText.includes === 'function') { - var messageTextLower = messageText.toLowerCase(); - for (var j = 0; j < puzzlePatterns.length; j++) { - if (messageTextLower.includes(puzzlePatterns[j].toLowerCase())) { - // DON'T remove the message box - just clear its content and hide it temporarily - $mo(this).empty().hide(); - self.showPuzzle({}); - found = true; - return false; - } - } - } - }); - } - - if (!found) { - - // Use exponential backoff to reduce performance impact - var delay = Math.min(1000 + (this.puzzleCheckCount * 100), 3000); // Max 3 seconds - - // Continue checking with increasing delay - setTimeout(function() { - self.checkForPuzzleMessage(); - }, delay); - } else { - // Reset counter when puzzle is found and shown - this.puzzleCheckCount = 0; - } - }, - - /** - * Generate a new puzzle using secure server-side generation - */ - generatePuzzle: function() { - var self = this; - - // CRITICAL FIX: Prevent duplicate calls - if (this.isGenerating) { - return; - } - - // Set generating flag to prevent duplicate calls - this.isGenerating = true; - - $mo.ajax({ - url: mo_osp_ajax.ajax_url, - type: 'POST', - data: { - action: 'mo_osp_generate_puzzle', - nonce: mo_osp_ajax.nonce - }, - success: function(response) { - // Reset generating flag - self.isGenerating = false; - - if (response.success && response.data.question) { - self.currentPuzzle = response.data.question; - // SECURITY: Never store the answer client-side - self.currentAnswer = null; - - // SECURITY ENHANCEMENT: Display puzzle as image to prevent bot bypass - if (response.data.image) { - // Show image, hide text - $mo('#mo-osp-puzzle-image').attr('src', response.data.image).show(); - $mo('#mo-osp-puzzle-text').hide(); - } else { - // Fallback to text if image generation failed - $mo('#mo-osp-puzzle-text').text(response.data.question).show(); - $mo('#mo-osp-puzzle-image').hide(); - } - - $mo('#mo-osp-puzzle-answer').val('').focus(); - self.hideError(); - - } else { - self.showError('Failed to generate puzzle. Please try again.'); - } - }, - error: function() { - // Reset generating flag on error - self.isGenerating = false; - self.showError('Failed to generate puzzle. Please try again.'); - } - }); - }, - - /** - * Show the puzzle popup - */ - showPuzzle: function(otpData) { - // Prevent duplicate calls - if puzzle is already showing, don't show again - if (this.isShowing) { - return; - } - - this.isShowing = true; - this.pendingOtpData = otpData; - this.generatePuzzle(); - - // CRITICAL: Ensure puzzle overlay has higher z-index than WooCommerce checkout popup - // WooCommerce checkout popup uses z-index: 100000, so puzzle needs to be higher - var $puzzleOverlay = $mo('#mo-osp-puzzle-overlay'); - $puzzleOverlay.css('z-index', '100001'); - $puzzleOverlay.removeClass('mo-osp-hidden'); - - // Also ensure puzzle popup container has high z-index - var $puzzlePopup = $mo('#mo-osp-puzzle-popup-outer-div'); - if ($puzzlePopup.length > 0) { - $puzzlePopup.css('z-index', '100002'); - } - - $mo('body').addClass('mo-osp-puzzle-open'); - $mo('#mo-osp-puzzle-answer').focus(); - }, - - /** - * Close the puzzle popup - */ - closePuzzle: function() { - this.isShowing = false; // Clear flag to allow future puzzle checks - this.isGenerating = false; // Reset generating flag - this.isVerifying = false; // Reset verifying flag - - // Hide puzzle overlay - $mo('#mo-osp-puzzle-overlay').addClass('mo-osp-hidden'); - - // CRITICAL: Also hide the outer wrapper div (especially important for WooCommerce checkout popup) - $mo('#mo-osp-puzzle-popup-outer-div').hide(); - - $mo('body').removeClass('mo-osp-puzzle-open'); - this.pendingOtpData = null; - this.hideError(); - // Re-enable verify button - $mo('#mo-osp-puzzle-verify').prop('disabled', false); - }, - - /** - * Verify puzzle answer using secure server-side validation - */ - verifyPuzzle: function() { - var self = this; - - // CRITICAL FIX: Prevent duplicate calls - if (this.isVerifying) { - return; - } - - var userAnswer = parseInt($mo('#mo-osp-puzzle-answer').val()); - - - if (isNaN(userAnswer)) { - this.showError('Please enter a valid number.'); - return; - } - - // Set verifying flag to prevent duplicate calls - this.isVerifying = true; - - // Disable verify button to prevent multiple clicks - $mo('#mo-osp-puzzle-verify').prop('disabled', true); - - // Store user's answer for later form submission - this.lastUserAnswer = userAnswer; - - - // SECURITY ENHANCEMENT: Verify puzzle through secure session-based AJAX endpoint - - $mo.ajax({ - url: mo_osp_ajax.ajax_url, - type: 'POST', - data: { - action: 'mo_osp_verify_puzzle', - nonce: mo_osp_ajax.nonce, - puzzle_answer: userAnswer, - // SECURITY: No longer sending question - server validates against session - email: this.getEmailFromForm(), - phone: this.getPhoneFromForm(), - browser_id: window.mo_osp_browser_id || window.MO_OTP_SpamPreventer.browserID || '' - }, - success: function(response) { - // Reset verifying flag - self.isVerifying = false; - $mo('#mo-osp-puzzle-verify').prop('disabled', false); - - if (response.success) { - - // Set global flag to indicate puzzle was just completed - window.mo_osp_puzzle_just_completed = true; - - // Store verification data for secure form submission - self.lastVerificationResponse = response.data; - - self.closePuzzle(); - - // Notify spam preventer of successful puzzle completion - if (typeof window.MO_OSP_SpamPreventer_onPuzzleSuccess !== 'undefined') { - window.MO_OSP_SpamPreventer_onPuzzleSuccess(); - } - - self.proceedWithOTP(); - } else { - // SECURITY: Check if puzzle was reset (new puzzle generated) - if (response.data && response.data.puzzle_reset) { - - // Check if image element exists - var $puzzleImage = $mo('#mo-osp-puzzle-image'); - var $puzzleText = $mo('#mo-osp-puzzle-text'); - - - // CRITICAL: Clear answer field FIRST before updating puzzle - $mo('#mo-osp-puzzle-answer').val('').attr('placeholder', '?'); - - // Handle puzzle image if provided - if (response.data.puzzle_image) { - - // Display new puzzle image (question is server-side only for security) - // Force image reload by adding timestamp to prevent caching - var imageUrl = response.data.puzzle_image; - var timestamp = new Date().getTime(); - - // Always add timestamp to force reload, even if URL already has query params - if (imageUrl.indexOf('?') === -1) { - imageUrl += '?t=' + timestamp; - } else { - imageUrl += '&t=' + timestamp; - } - - - if ($puzzleImage.length > 0) { - // CRITICAL: Use .on('load') to ensure image is loaded before showing - $puzzleImage.off('load error').on('load', function() { - $mo(this).show(); - $puzzleText.hide(); - }).on('error', function() { - console.error('[Puzzle] ERROR: Failed to load puzzle image'); - // Fallback: show text if image fails - if ($puzzleText.length > 0) { - $puzzleText.text('New puzzle generated. Please refresh if image does not appear.').show(); - } - $mo(this).hide(); - }); - - // Set the src AFTER binding load handler - var oldSrc = $puzzleImage.attr('src'); - - // Force image reload by setting src - if (oldSrc === imageUrl) { - // If URL is same (shouldn't happen with timestamp), force reload by clearing first - $puzzleImage.attr('src', ''); - setTimeout(function() { - $puzzleImage.attr('src', imageUrl); - }, 50); - } else { - $puzzleImage.attr('src', imageUrl); - } - - // Ensure image is visible (in case it was hidden) - $puzzleImage.show(); - $puzzleText.hide(); - } else { - console.error('[Puzzle] ERROR: Puzzle image element not found!'); - } - } else if (response.data.puzzle_question) { - // Fallback: show puzzle question as text if image not available - if ($puzzleText.length > 0) { - $puzzleText.text(response.data.puzzle_question).show(); - $puzzleImage.hide(); - } - } else { - console.error('[Puzzle] ERROR: No puzzle image or question provided in response!'); - // Show generic message - if ($puzzleText.length > 0) { - $puzzleText.text('A new puzzle has been generated. Please try again.').show(); - } - $puzzleImage.hide(); - } - - // Clear puzzle state - self.currentPuzzle = null; - self.currentAnswer = null; - - // Hide error temporarily while new puzzle loads - self.hideError(); - - // Show error message after a brief delay to ensure puzzle image is visible - setTimeout(function() { - self.showError(response.data.message || 'Incorrect answer. A new puzzle has been generated. Please solve it.'); - $mo('#mo-osp-puzzle-answer').focus(); - }, 200); - } else { - // No puzzle reset - same puzzle, just show error and allow retry - - // Just clear the answer field and show error - puzzle stays the same - self.showError(response.data.message || 'Incorrect answer. Please try again.'); - $mo('#mo-osp-puzzle-answer').val('').focus(); - } - - // Ensure verify button stays visible after incorrect answer - $mo('#mo-osp-puzzle-verify').show().prop('disabled', false); - } - }, - error: function(xhr, status, error) { - console.error('Puzzle verification AJAX error: ' + status + ' - ' + error); - // Reset verifying flag - self.isVerifying = false; - self.showError('Verification failed. Please try again.'); - // Ensure verify button stays visible after error - $mo('#mo-osp-puzzle-verify').show().prop('disabled', false); - } - }); - }, - - /** - * Proceed with OTP sending after successful puzzle verification - */ - proceedWithOTP: function() { - var self = this; // Store reference to this for use in nested functions - - // Get verification data from the last successful verification - var verificationData = this.lastVerificationResponse || {}; - - // Add secure puzzle verification data to all forms - $mo('form').each(function() { - var $moform = $mo(this); - - // Remove any old puzzle processed flags - $moform.find('input[name="mo_osp_puzzle_processed"]').remove(); - - // Add puzzle verification flag - if (!$moform.find('input[name="puzzle_verified"]').length) { - $moform.append('<input type="hidden" name="puzzle_verified" value="true">'); - } - - if (verificationData.puzzle_nonce && !$moform.find('input[name="mo_osp_puzzle_nonce"]').length) { - $moform.append('<input type="hidden" name="mo_osp_puzzle_nonce" value="' + verificationData.puzzle_nonce + '">'); - } - - if (verificationData.verification_token && !$moform.find('input[name="verification_token"]').length) { - $moform.append('<input type="hidden" name="verification_token" value="' + verificationData.verification_token + '">'); - } - - // Add the user's puzzle answer for server verification - if (self.lastUserAnswer && !$moform.find('input[name="puzzle_answer"]').length) { - $moform.append('<input type="hidden" name="puzzle_answer" value="' + self.lastUserAnswer + '">'); - } - - // SECURITY: No longer sending puzzle question - server has it in session - }); - - // Set global verification flag for AJAX interception - window.mo_osp_puzzle_verified = true; - - - // PRIORITY 1: Check for external popup callback (highest priority) - if (typeof window.MO_OSP_Puzzle_onExternalPopupSuccess === 'function') { - try { - // Close puzzle before calling callback - self.closePuzzle(); - window.MO_OSP_Puzzle_onExternalPopupSuccess(verificationData); - } catch (e) { - console.error('Error in external popup success callback:', e); - // Ensure puzzle is closed even on error - self.closePuzzle(); - } - return; - } - - // PRIORITY 2: Check if we're in AJAX form context (callback registered by spam-preventer.js) - if (typeof window.MO_OSP_Puzzle_onAjaxSuccess === 'function') { - try { - // Close puzzle before calling callback - self.closePuzzle(); - window.MO_OSP_Puzzle_onAjaxSuccess(verificationData); - } catch (e) { - console.error('Error in AJAX success callback:', e); - // Ensure puzzle is closed even on error - self.closePuzzle(); - } - return; - } - - // Check if we're in popup context (callback registered by popup-timer.js or inline script) - if (typeof window.MO_OSP_Puzzle_onPopupSuccess === 'function') { - try { - // Close puzzle before calling callback - self.closePuzzle(); - window.MO_OSP_Puzzle_onPopupSuccess(); - } catch (e) { - console.error('Error in popup success callback:', e); - // Ensure puzzle is closed even on error - self.closePuzzle(); - } - return; - } - - - // Check if we're in a popup (DefaultPopup) - look for resend form - var resendForm = document.getElementById('verification_resend_otp_form'); - if (resendForm) { - // CRITICAL: Close puzzle before submitting form - self.closePuzzle(); - - // Add puzzle verification data to the resend form - var puzzleVerifiedInput = document.createElement('input'); - puzzleVerifiedInput.type = 'hidden'; - puzzleVerifiedInput.name = 'puzzle_verified'; - puzzleVerifiedInput.value = 'true'; - resendForm.appendChild(puzzleVerifiedInput); - - if (verificationData.puzzle_nonce) { - var nonceInput = document.createElement('input'); - nonceInput.type = 'hidden'; - nonceInput.name = 'mo_osp_puzzle_nonce'; - nonceInput.value = verificationData.puzzle_nonce; - resendForm.appendChild(nonceInput); - } - - if (verificationData.verification_token) { - var tokenInput = document.createElement('input'); - tokenInput.type = 'hidden'; - tokenInput.name = 'verification_token'; - tokenInput.value = verificationData.verification_token; - resendForm.appendChild(tokenInput); - } - - // Submit the resend form to trigger OTP sending - resendForm.submit(); - return; - } - - - // CRITICAL FIX: For popup context, show OTP form instead of reloading - // Check if we're in a popup (DefaultPopup was shown) - var $moPopup = $mo('#mo_site_otp_form, .mo_customer_validation-modal'); - if ($moPopup.length > 0) { - - // CRITICAL: Close puzzle before showing OTP popup again - self.closePuzzle(); - - // Show the popup again (it was hidden when puzzle was shown) - $mo('#mo_site_otp_form').show(); - $mo('.mo_customer_validation-modal').show(); - $mo('.mo-modal-backdrop').show(); - - // Update the message in popup to show OTP form message - var $moPopupBody = $mo('.mo_customer_validation-modal-body'); - if ($moPopupBody.length > 0) { - // Check if OTP form exists in popup - var $moOtpForm = $mo('#mo_validate_form'); - if ($moOtpForm.length > 0) { - // OTP form exists, trigger OTP sending via resend link - var $moResendLink = $mo('a.mo-resend, a[onclick*="mo_otp_verification_resend"]'); - if ($moResendLink.length > 0) { - // Add puzzle verification data to the OTP form first - var $moOtpFormInputs = $moOtpForm; - if (!$moOtpFormInputs.find('input[name="puzzle_verified"]').length) { - $moOtpFormInputs.append('<input type="hidden" name="puzzle_verified" value="true">'); - } - if (verificationData.puzzle_nonce && !$moOtpFormInputs.find('input[name="mo_osp_puzzle_nonce"]').length) { - $moOtpFormInputs.append('<input type="hidden" name="mo_osp_puzzle_nonce" value="' + verificationData.puzzle_nonce + '">'); - } - if (verificationData.verification_token && !$moOtpFormInputs.find('input[name="verification_token"]').length) { - $moOtpFormInputs.append('<input type="hidden" name="verification_token" value="' + verificationData.verification_token + '">'); - } - // Trigger resend to send OTP - $moResendLink.trigger('click'); - return; - } - } - } - } - - // Fallback: For regular forms, we need to resubmit the original form - // Since puzzle is verified, the next OTP request should succeed - - // Try to find the original login/submission form - var $moOriginalForm = $mo('form[name="loginform"], form#loginform, form.wp-login-form'); - if ($moOriginalForm.length === 0) { - // Try other common form selectors - $moOriginalForm = $mo('form').not('#mo_validate_form').not('#validation_goBack_form').not('#verification_resend_otp_form').first(); - } - - if ($moOriginalForm.length > 0) { - // Add puzzle verification data to the form - if (!$moOriginalForm.find('input[name="puzzle_verified"]').length) { - $moOriginalForm.append('<input type="hidden" name="puzzle_verified" value="true">'); - } - if (verificationData.puzzle_nonce && !$moOriginalForm.find('input[name="mo_osp_puzzle_nonce"]').length) { - $moOriginalForm.append('<input type="hidden" name="mo_osp_puzzle_nonce" value="' + verificationData.puzzle_nonce + '">'); - } - if (verificationData.verification_token && !$moOriginalForm.find('input[name="verification_token"]').length) { - $moOriginalForm.append('<input type="hidden" name="verification_token" value="' + verificationData.verification_token + '">'); - } - // Submit the form to trigger OTP sending - $moOriginalForm.submit(); - return; - } - - // Last resort: Reload the page - // Store puzzle completion flag in sessionStorage - sessionStorage.setItem('mo_osp_puzzle_completed', 'true'); - // Reload to show OTP form - window.location.reload(); - }, - - /** - * Click the Send OTP button after puzzle verification - */ - clickSendOTPButton: function() { - - // Find and click the send OTP button - var sendButton = this.findSendOTPButton(); - - if (sendButton && sendButton.length > 0) { - // Trigger OTP send directly - this.triggerOTPSendDirectly(); - } else { - // Reset the flag after a short delay to allow user to click manually - setTimeout(function() { - window.mo_otp_button_clicked = false; - }, 2000); - } - }, - - /** - * Trigger OTP send directly - */ - triggerOTPSendDirectly: function() { - - // Find the send OTP button - var sendButton = this.findSendOTPButton(); - - if (sendButton && sendButton.length > 0) { - - // Trigger the button click - sendButton.trigger('click'); - - } else { - // Show error message - var messageBox = $mo('#mo_message, .mo_message, [id*="mo_message"]').first(); - if (messageBox.length > 0) { - messageBox.empty().append('Error: Could not find Send OTP button.').css({ - 'color': '#ff5b5b', - 'background': '#ffefef', - 'padding': '10px', - 'border-radius': '5px' - }); - } else { - // Fallback: Create temporary message - $mo('body').append('<div id="mo_osp_temp_message" style="position: fixed; top: 20px; right: 20px; background: #ffefef; color: #ff5b5b; padding: 10px; border-radius: 5px; z-index: 9999;">Error: Could not find Send OTP button.</div>'); - setTimeout(function() { - $mo('#mo_osp_temp_message').remove(); - }, 5000); - } - } - }, - - /** - * Find the Send OTP button on the page - */ - findSendOTPButton: function() { - // Try multiple selectors to find the send OTP button - var buttonSelectors = [ - 'button#miniorange_wc_popup_send_otp_token', - '#mo_wc_send_otp', - 'input[id*="send_otp"]', - 'button[id*="send_otp"]', - 'input[value*="Send OTP"]', - 'button[value*="Send OTP"]', - 'input[id*="mo_wc_send_otp"]', - 'button[id*="mo_wc_send_otp"]', - '.mo-send-otp-button', - '[class*="send-otp"]', - 'input[name*="send_otp"]', - 'button[name*="send_otp"]' - ]; - - for (var i = 0; i < buttonSelectors.length; i++) { - var button = $mo(buttonSelectors[i]); - if (button.length > 0) { - return button.first(); - } - } - return null; - }, - - /** - * Intercept AJAX calls to add puzzle verification data - */ - interceptAjaxCalls: function() { - - var self = this; - var originalAjax = $mo.ajax; - - $mo.ajax = function(options) { - // Get verification data - var verificationData = self.lastVerificationResponse || {}; - - // Check for AJAX actions that might be OTP-related - var otpActions = [ - 'miniorange_ajax_otp', - 'mo_ajax_form_validate', - 'mo_send_otp', - 'mo_resend_otp', - 'woocommerce_checkout' - ]; - - var isOtpRelated = false; - if (options.data) { - var dataStr = typeof options.data === 'string' ? options.data : JSON.stringify(options.data); - for (var i = 0; i < otpActions.length; i++) { - if (dataStr.includes(otpActions[i]) || - dataStr.includes('send_otp') || dataStr.includes('verify_otp')) { - isOtpRelated = true; - break; - } - } - } - - if (isOtpRelated && window.mo_osp_puzzle_verified) { - - // Add puzzle verification data to AJAX request - if (typeof options.data === 'string') { - if (!options.data.includes('puzzle_verified')) { - options.data += '&puzzle_verified=true'; - } - if (verificationData.puzzle_nonce && !options.data.includes('mo_osp_puzzle_nonce')) { - options.data += '&mo_osp_puzzle_nonce=' + encodeURIComponent(verificationData.puzzle_nonce); - } - if (verificationData.verification_token && !options.data.includes('verification_token')) { - options.data += '&verification_token=' + encodeURIComponent(verificationData.verification_token); - } - if (window.MO_OSP_Puzzle.lastUserAnswer && !options.data.includes('puzzle_answer')) { - options.data += '&puzzle_answer=' + encodeURIComponent(window.MO_OSP_Puzzle.lastUserAnswer); - } - if (!options.data.includes('mo_osp_browser_id')) { - options.data += '&mo_osp_browser_id=' + encodeURIComponent(window.mo_osp_browser_id || ''); - } - } else { - // If data is null/undefined, create new data object with secure verification - options.data = options.data || {}; - options.data.mo_osp_browser_id = window.mo_osp_browser_id || ''; - - if (window.mo_osp_puzzle_verified) { - options.data.puzzle_verified = 'true'; - if (verificationData.puzzle_nonce) { - options.data.mo_osp_puzzle_nonce = verificationData.puzzle_nonce; - } - if (verificationData.verification_token) { - options.data.verification_token = verificationData.verification_token; - } - if (window.MO_OSP_Puzzle.lastUserAnswer) { - options.data.puzzle_answer = window.MO_OSP_Puzzle.lastUserAnswer; - } - // SECURITY: No longer sending puzzle question - server has it in session - } - } - - - // Wrap the success callback for universal handling - var originalSuccess = options.success; - options.success = function(response) { - - // Check if response contains puzzle requirement (multiple detection methods) - var isPuzzleRequired = false; - var detectionMethod = ''; - - // Method 1: Check structured response format - if (response && (response.puzzle_required === true || response.authType === 'PUZZLE_REQUIRED' || response.result === 'puzzle_required')) { - isPuzzleRequired = true; - detectionMethod = 'structured_response'; - } - // Method 2: Check message content (fallback) - else if (response && response.message && - response.message.includes('Please complete the security verification to continue')) { - isPuzzleRequired = true; - detectionMethod = 'message_content'; - } - - if (isPuzzleRequired) { - - // Call original success handler first - if (originalSuccess && typeof originalSuccess === 'function') { - originalSuccess.call(this, response); - } - - // Wait a moment for DOM to update, then show puzzle - setTimeout(function() { - if (typeof MO_OSP_Puzzle !== 'undefined' && !MO_OSP_Puzzle.isShowing) { - MO_OSP_Puzzle.showPuzzle({}); - } - }, 500); - - // Also trigger manual puzzle check as fallback - setTimeout(function() { - if (typeof MO_OSP_Puzzle !== 'undefined') { - MO_OSP_Puzzle.triggerPuzzleCheck(); - } - }, 1000); - return; - } - - // Call original success handler first - let it handle all UI updates - if (originalSuccess && typeof originalSuccess === 'function') { - originalSuccess.call(this, response); - } - }; - - // Also wrap error callback to catch puzzle requirements in error responses - var originalError = options.error; - options.error = function(xhr, status, error) { - - // Try to parse error response for puzzle requirements - try { - var errorResponse = JSON.parse(xhr.responseText); - if (errorResponse && (errorResponse.puzzle_required === true || errorResponse.authType === 'PUZZLE_REQUIRED' || errorResponse.result === 'puzzle_required')) { - - - // Show puzzle for error response - setTimeout(function() { - if (typeof MO_OSP_Puzzle !== 'undefined' && !MO_OSP_Puzzle.isShowing) { - MO_OSP_Puzzle.showPuzzle({}); - } - }, 500); - - // Don't call original error handler for puzzle requirements - return; - } - } catch (e) { - // Not JSON or parsing failed, continue with normal error handling - } - - // Call original error handler for non-puzzle errors - if (originalError && typeof originalError === 'function') { - originalError.call(this, xhr, status, error); - } - }; - } - - // Call the original ajax function - return originalAjax.call(this, options); - }; - - // Restore original AJAX after 10 seconds (longer for universal coverage) - setTimeout(function() { - $mo.ajax = originalAjax; - - }, 10000); - }, - - /** - * Manual trigger for puzzle when we know it should appear (after OTP request) - */ - triggerPuzzleCheck: function() { - - var self = this; - - // Reset counter for new check sequence - this.puzzleCheckCount = 0; - - // Check immediately - this.checkForPuzzleMessage(); - - // Only check a few more times with reasonable delays (not aggressive) - setTimeout(function() { self.checkForPuzzleMessage(); }, 500); - setTimeout(function() { self.checkForPuzzleMessage(); }, 1500); - }, - - /** - * Get email from form fields - */ - getEmailFromForm: function() { - var email = ''; - - // Try to get from visible email inputs first - $mo('input[type="email"]:visible, input[name*="email"]:visible, input[id*="email"]:visible').each(function() { - var $field = $mo(this); - var type = ($field.attr('type') || '').toLowerCase(); - if (type === 'button' || type === 'submit' || type === 'reset') { - return; - } - var value = $field.val(); - if (value && !/send\s+otp|verify\s+otp/i.test(value)) { - email = value; - return false; // Break loop - } - }); - - // Fallback to hidden inputs (from popup forms with extra_post_data) - if (!email) { - $mo('input[type="hidden"][name*="email"], input[type="hidden"][id*="email"]').each(function() { - if ($mo(this).val()) { - email = $mo(this).val(); - return false; // Break loop - } - }); - } - - - return email; - }, - - /** - * Get phone from form fields - */ - getPhoneFromForm: function() { - var phone = ''; - - // Try to get from visible phone inputs first - $mo('input[type="tel"]:visible, input[name*="phone"]:visible, input[id*="phone"]:visible, input[name*="mobile"]:visible').each(function() { - var $field = $mo(this); - var type = ($field.attr('type') || '').toLowerCase(); - if (type === 'button' || type === 'submit' || type === 'reset') { - return; - } - var value = $field.val(); - if (!value || /send\s+otp|verify\s+otp/i.test(value)) { - return; - } - // Normalize to digits/+ and require a minimum length to avoid tokens like "6ff2c895dc". - var normalized = String(value).replace(/[^0-9+]/g, ''); - var digitCount = normalized.replace(/\D/g, '').length; - if (digitCount >= 6) { - phone = normalized; - return false; // Break loop - } - }); - - // Fallback to hidden inputs (from popup forms with extra_post_data) - if (!phone) { - $mo('input[type="hidden"][name*="phone"], input[type="hidden"][id*="phone"], input[type="hidden"][name*="mobile"]').each(function() { - var value = $mo(this).val(); - if (!value || /send\s+otp|verify\s+otp/i.test(value)) { - return; - } - var normalized = String(value).replace(/[^0-9+]/g, ''); - var digitCount = normalized.replace(/\D/g, '').length; - if (digitCount >= 6) { - phone = normalized; - return false; // Break loop - } - }); - } - - - return phone; - }, - - /** - * Show error message in puzzle popup - */ - showError: function(message) { - $mo('#mo-osp-puzzle-error-text').text(message); - $mo('#mo-osp-puzzle-error').show(); - }, - - /** - * Hide error message in puzzle popup - */ - hideError: function() { - $mo('#mo-osp-puzzle-error').hide(); - } - }; - - // Initialize puzzle system when document is ready - $mo(document).ready(function() { - // Initialize puzzle system only when needed (not automatically on every page load) - // Puzzle will be initialized when OTP request triggers puzzle requirement - if (typeof MO_OSP_Puzzle !== 'undefined') { - // Initialize fully - bind events and mark as initialized - MO_OSP_Puzzle.init(); - MO_OSP_Puzzle.initialized = true; - } - }); - -})(jQuery); +/** + * OTP Puzzle Verification System + * + * Secure puzzle system for human verification before OTP sending. + * Features: + * - Server-side puzzle generation and validation + * - Session-based security with fallback storage + * - Multiple detection methods for puzzle requirements + * - Comprehensive error handling and logging + * + * @package otpspampreventer + */ + +(function($mo) { + 'use strict'; + + // Puzzle Verification System + window.MO_OSP_Puzzle = { + // State variables + currentPuzzle: null, + currentAnswer: null, + lastUserAnswer: null, + pendingOtpData: null, + isShowing: false, + puzzleCheckCount: 0, + lastVerificationResponse: null, + isGenerating: false, // Flag to prevent duplicate generate calls + isVerifying: false, // Flag to prevent duplicate verify calls + eventsBound: false, // Flag to prevent duplicate event binding + + /** + * Initialize the puzzle system + */ + init: function() { + // Only bind events once to prevent duplicate handlers + if (!this.eventsBound) { + this.bindEvents(); + this.eventsBound = true; + } + this.puzzleCheckCount = 0; // Initialize counter to prevent infinite loops + }, + + /** + * Bind all puzzle-related event handlers + * CRITICAL: Unbind previous handlers first to prevent duplicate event binding + */ + bindEvents: function() { + var self = this; + + // CRITICAL FIX: Unbind all previous handlers to prevent duplicate event binding + $mo(document).off('click', '.mo-osp-puzzle-close'); + $mo(document).off('click', '.mo-osp-puzzle-popup'); + $mo(document).off('click', '#mo-osp-puzzle-refresh'); + $mo(document).off('click', '#mo-osp-puzzle-verify'); + $mo(document).off('keypress', '#mo-osp-puzzle-answer'); + $mo(document).off('input', '#mo-osp-puzzle-answer'); + + // Close puzzle popup + $mo(document).on('click', '.mo-osp-puzzle-close', function() { + self.closePuzzle(); + }); + + // Prevent closing when clicking inside popup + $mo(document).on('click', '.mo-osp-puzzle-popup', function(e) { + e.stopPropagation(); + }); + + // Refresh puzzle + $mo(document).on('click', '#mo-osp-puzzle-refresh', function() { + self.generatePuzzle(); + $mo('#mo-osp-puzzle-answer').val('').focus(); + }); + + // Verify puzzle - prevent duplicate calls + $mo(document).on('click', '#mo-osp-puzzle-verify', function(e) { + e.preventDefault(); + e.stopPropagation(); + + // Prevent duplicate calls - check if verification is already in progress + if (self.isVerifying) { + return false; + } + + self.verifyPuzzle(); + return false; + }); + + // Enter key in puzzle input - prevent duplicate calls + $mo(document).on('keypress', '#mo-osp-puzzle-answer', function(e) { + if (e.which === 13) { // Enter key + e.preventDefault(); + + // Prevent duplicate calls + if (self.isVerifying) { + return false; + } + + self.verifyPuzzle(); + return false; + } + }); + + // Clear error on input + $mo(document).on('input', '#mo-osp-puzzle-answer', function() { + self.hideError(); + }); + + // Note: checkForPuzzleMessage() is now only called when OTP request is made + // This prevents puzzle popup from showing on every page load + }, + + /** + * Check for puzzle requirement messages in DOM + * Uses circuit breaker to prevent infinite loops + */ + checkForPuzzleMessage: function() { + var self = this; + + // Prevent infinite loop - only check if puzzle is not already showing + if (self.isShowing) { + return; + } + + // Circuit breaker: Stop checking after 30 attempts (30 seconds) to prevent infinite loops + if (!this.puzzleCheckCount) { + this.puzzleCheckCount = 0; + } + this.puzzleCheckCount++; + + if (this.puzzleCheckCount > 30) { + return; + } + + + // Look for the puzzle message in various message containers + var messageSelectors = [ + '#mo_message', + '.mo_message', + '[id*="mo_message"]', + '.woocommerce-message', + '.notice', + '.alert', + '[class*="message"]', + '.mo-otp-message', + 'div[style*="color"]', // Catch styled message divs + '.error', + '.success', + '.info' + ]; + + var found = false; + var allMessages = []; + + // Specific puzzle message patterns (must be precise to avoid false positives) + var puzzlePatterns = [ + 'Please complete the security verification to continue', + 'solve this simple puzzle to verify you are human before sending an OTP', + 'complete the security verification to continue', + 'puzzle to verify you are human before sending', + 'security purposes, please solve this simple puzzle' + ]; + + for (var i = 0; i < messageSelectors.length && !found; i++) { + $mo(messageSelectors[i]).each(function() { + var messageText = $mo(this).text().trim(); + if (messageText) { + allMessages.push({ + selector: messageSelectors[i], + text: messageText, + element: this + }); + } + + // Check against multiple puzzle patterns + if (messageText && typeof messageText.includes === 'function') { + var messageTextLower = messageText.toLowerCase(); + for (var j = 0; j < puzzlePatterns.length; j++) { + if (messageTextLower.includes(puzzlePatterns[j].toLowerCase())) { + // DON'T remove the message box - just clear its content and hide it temporarily + $mo(this).empty().hide(); + self.showPuzzle({}); + found = true; + return false; + } + } + } + }); + } + + if (!found) { + + // Use exponential backoff to reduce performance impact + var delay = Math.min(1000 + (this.puzzleCheckCount * 100), 3000); // Max 3 seconds + + // Continue checking with increasing delay + setTimeout(function() { + self.checkForPuzzleMessage(); + }, delay); + } else { + // Reset counter when puzzle is found and shown + this.puzzleCheckCount = 0; + } + }, + + /** + * Generate a new puzzle using secure server-side generation + */ + generatePuzzle: function() { + var self = this; + + // CRITICAL FIX: Prevent duplicate calls + if (this.isGenerating) { + return; + } + + // Set generating flag to prevent duplicate calls + this.isGenerating = true; + + $mo.ajax({ + url: mo_osp_ajax.ajax_url, + type: 'POST', + data: { + action: 'mo_osp_generate_puzzle', + nonce: mo_osp_ajax.nonce + }, + success: function(response) { + // Reset generating flag + self.isGenerating = false; + + if (response.success && response.data.question) { + self.currentPuzzle = response.data.question; + // SECURITY: Never store the answer client-side + self.currentAnswer = null; + + // SECURITY ENHANCEMENT: Display puzzle as image to prevent bot bypass + if (response.data.image) { + // Show image, hide text + $mo('#mo-osp-puzzle-image').attr('src', response.data.image).show(); + $mo('#mo-osp-puzzle-text').hide(); + } else { + // Fallback to text if image generation failed + $mo('#mo-osp-puzzle-text').text(response.data.question).show(); + $mo('#mo-osp-puzzle-image').hide(); + } + + $mo('#mo-osp-puzzle-answer').val('').focus(); + self.hideError(); + + } else { + self.showError('Failed to generate puzzle. Please try again.'); + } + }, + error: function() { + // Reset generating flag on error + self.isGenerating = false; + self.showError('Failed to generate puzzle. Please try again.'); + } + }); + }, + + /** + * Show the puzzle popup + */ + showPuzzle: function(otpData) { + // Prevent duplicate calls - if puzzle is already showing, don't show again + if (this.isShowing) { + return; + } + + this.isShowing = true; + this.pendingOtpData = otpData; + this.generatePuzzle(); + + // CRITICAL: Ensure puzzle overlay has higher z-index than WooCommerce checkout popup + // WooCommerce checkout popup uses z-index: 100000, so puzzle needs to be higher + var $puzzleOverlay = $mo('#mo-osp-puzzle-overlay'); + $puzzleOverlay.css('z-index', '100001'); + $puzzleOverlay.removeClass('mo-osp-hidden'); + + // Also ensure puzzle popup container has high z-index + var $puzzlePopup = $mo('#mo-osp-puzzle-popup-outer-div'); + if ($puzzlePopup.length > 0) { + $puzzlePopup.css('z-index', '100002'); + } + + $mo('body').addClass('mo-osp-puzzle-open'); + $mo('#mo-osp-puzzle-answer').focus(); + }, + + /** + * Close the puzzle popup + */ + closePuzzle: function() { + this.isShowing = false; // Clear flag to allow future puzzle checks + this.isGenerating = false; // Reset generating flag + this.isVerifying = false; // Reset verifying flag + + // Hide puzzle overlay + $mo('#mo-osp-puzzle-overlay').addClass('mo-osp-hidden'); + + // CRITICAL: Also hide the outer wrapper div (especially important for WooCommerce checkout popup) + $mo('#mo-osp-puzzle-popup-outer-div').hide(); + + $mo('body').removeClass('mo-osp-puzzle-open'); + this.pendingOtpData = null; + this.hideError(); + // Re-enable verify button + $mo('#mo-osp-puzzle-verify').prop('disabled', false); + }, + + /** + * Verify puzzle answer using secure server-side validation + */ + verifyPuzzle: function() { + var self = this; + + // CRITICAL FIX: Prevent duplicate calls + if (this.isVerifying) { + return; + } + + var userAnswer = parseInt($mo('#mo-osp-puzzle-answer').val()); + + + if (isNaN(userAnswer)) { + this.showError('Please enter a valid number.'); + return; + } + + // Set verifying flag to prevent duplicate calls + this.isVerifying = true; + + // Disable verify button to prevent multiple clicks + $mo('#mo-osp-puzzle-verify').prop('disabled', true); + + // Store user's answer for later form submission + this.lastUserAnswer = userAnswer; + + + // SECURITY ENHANCEMENT: Verify puzzle through secure session-based AJAX endpoint + + $mo.ajax({ + url: mo_osp_ajax.ajax_url, + type: 'POST', + data: { + action: 'mo_osp_verify_puzzle', + nonce: mo_osp_ajax.nonce, + puzzle_answer: userAnswer, + // SECURITY: No longer sending question - server validates against session + email: this.getEmailFromForm(), + phone: this.getPhoneFromForm(), + browser_id: window.mo_osp_browser_id || window.MO_OTP_SpamPreventer.browserID || '' + }, + success: function(response) { + // Reset verifying flag + self.isVerifying = false; + $mo('#mo-osp-puzzle-verify').prop('disabled', false); + + if (response.success) { + + // Set global flag to indicate puzzle was just completed + window.mo_osp_puzzle_just_completed = true; + + // Store verification data for secure form submission + self.lastVerificationResponse = response.data; + + self.closePuzzle(); + + // Notify spam preventer of successful puzzle completion + if (typeof window.MO_OSP_SpamPreventer_onPuzzleSuccess !== 'undefined') { + window.MO_OSP_SpamPreventer_onPuzzleSuccess(); + } + + self.proceedWithOTP(); + } else { + // SECURITY: Check if puzzle was reset (new puzzle generated) + if (response.data && response.data.puzzle_reset) { + + // Check if image element exists + var $puzzleImage = $mo('#mo-osp-puzzle-image'); + var $puzzleText = $mo('#mo-osp-puzzle-text'); + + + // CRITICAL: Clear answer field FIRST before updating puzzle + $mo('#mo-osp-puzzle-answer').val('').attr('placeholder', '?'); + + // Handle puzzle image if provided + if (response.data.puzzle_image) { + + // Display new puzzle image (question is server-side only for security) + // Force image reload by adding timestamp to prevent caching + var imageUrl = response.data.puzzle_image; + var timestamp = new Date().getTime(); + + // Always add timestamp to force reload, even if URL already has query params + if (imageUrl.indexOf('?') === -1) { + imageUrl += '?t=' + timestamp; + } else { + imageUrl += '&t=' + timestamp; + } + + + if ($puzzleImage.length > 0) { + // CRITICAL: Use .on('load') to ensure image is loaded before showing + $puzzleImage.off('load error').on('load', function() { + $mo(this).show(); + $puzzleText.hide(); + }).on('error', function() { + console.error('[Puzzle] ERROR: Failed to load puzzle image'); + // Fallback: show text if image fails + if ($puzzleText.length > 0) { + $puzzleText.text('New puzzle generated. Please refresh if image does not appear.').show(); + } + $mo(this).hide(); + }); + + // Set the src AFTER binding load handler + var oldSrc = $puzzleImage.attr('src'); + + // Force image reload by setting src + if (oldSrc === imageUrl) { + // If URL is same (shouldn't happen with timestamp), force reload by clearing first + $puzzleImage.attr('src', ''); + setTimeout(function() { + $puzzleImage.attr('src', imageUrl); + }, 50); + } else { + $puzzleImage.attr('src', imageUrl); + } + + // Ensure image is visible (in case it was hidden) + $puzzleImage.show(); + $puzzleText.hide(); + } else { + console.error('[Puzzle] ERROR: Puzzle image element not found!'); + } + } else if (response.data.puzzle_question) { + // Fallback: show puzzle question as text if image not available + if ($puzzleText.length > 0) { + $puzzleText.text(response.data.puzzle_question).show(); + $puzzleImage.hide(); + } + } else { + console.error('[Puzzle] ERROR: No puzzle image or question provided in response!'); + // Show generic message + if ($puzzleText.length > 0) { + $puzzleText.text('A new puzzle has been generated. Please try again.').show(); + } + $puzzleImage.hide(); + } + + // Clear puzzle state + self.currentPuzzle = null; + self.currentAnswer = null; + + // Hide error temporarily while new puzzle loads + self.hideError(); + + // Show error message after a brief delay to ensure puzzle image is visible + setTimeout(function() { + self.showError(response.data.message || 'Incorrect answer. A new puzzle has been generated. Please solve it.'); + $mo('#mo-osp-puzzle-answer').focus(); + }, 200); + } else { + // No puzzle reset - same puzzle, just show error and allow retry + + // Just clear the answer field and show error - puzzle stays the same + self.showError(response.data.message || 'Incorrect answer. Please try again.'); + $mo('#mo-osp-puzzle-answer').val('').focus(); + } + + // Ensure verify button stays visible after incorrect answer + $mo('#mo-osp-puzzle-verify').show().prop('disabled', false); + } + }, + error: function(xhr, status, error) { + console.error('Puzzle verification AJAX error: ' + status + ' - ' + error); + // Reset verifying flag + self.isVerifying = false; + self.showError('Verification failed. Please try again.'); + // Ensure verify button stays visible after error + $mo('#mo-osp-puzzle-verify').show().prop('disabled', false); + } + }); + }, + + /** + * Proceed with OTP sending after successful puzzle verification + */ + proceedWithOTP: function() { + var self = this; // Store reference to this for use in nested functions + + // Get verification data from the last successful verification + var verificationData = this.lastVerificationResponse || {}; + + // Add secure puzzle verification data to all forms + $mo('form').each(function() { + var $moform = $mo(this); + + // Remove any old puzzle processed flags + $moform.find('input[name="mo_osp_puzzle_processed"]').remove(); + + // Add puzzle verification flag + if (!$moform.find('input[name="puzzle_verified"]').length) { + $moform.append('<input type="hidden" name="puzzle_verified" value="true">'); + } + + if (verificationData.puzzle_nonce && !$moform.find('input[name="mo_osp_puzzle_nonce"]').length) { + $moform.append('<input type="hidden" name="mo_osp_puzzle_nonce" value="' + verificationData.puzzle_nonce + '">'); + } + + if (verificationData.verification_token && !$moform.find('input[name="verification_token"]').length) { + $moform.append('<input type="hidden" name="verification_token" value="' + verificationData.verification_token + '">'); + } + + // Add the user's puzzle answer for server verification + if (self.lastUserAnswer && !$moform.find('input[name="puzzle_answer"]').length) { + $moform.append('<input type="hidden" name="puzzle_answer" value="' + self.lastUserAnswer + '">'); + } + + // SECURITY: No longer sending puzzle question - server has it in session + }); + + // Set global verification flag for AJAX interception + window.mo_osp_puzzle_verified = true; + + + // PRIORITY 1: Check for external popup callback (highest priority) + if (typeof window.MO_OSP_Puzzle_onExternalPopupSuccess === 'function') { + try { + // Close puzzle before calling callback + self.closePuzzle(); + window.MO_OSP_Puzzle_onExternalPopupSuccess(verificationData); + } catch (e) { + console.error('Error in external popup success callback:', e); + // Ensure puzzle is closed even on error + self.closePuzzle(); + } + return; + } + + // PRIORITY 2: Check if we're in AJAX form context (callback registered by spam-preventer.js) + if (typeof window.MO_OSP_Puzzle_onAjaxSuccess === 'function') { + try { + // Close puzzle before calling callback + self.closePuzzle(); + window.MO_OSP_Puzzle_onAjaxSuccess(verificationData); + } catch (e) { + console.error('Error in AJAX success callback:', e); + // Ensure puzzle is closed even on error + self.closePuzzle(); + } + return; + } + + // Check if we're in popup context (callback registered by popup-timer.js or inline script) + if (typeof window.MO_OSP_Puzzle_onPopupSuccess === 'function') { + try { + // Close puzzle before calling callback + self.closePuzzle(); + window.MO_OSP_Puzzle_onPopupSuccess(); + } catch (e) { + console.error('Error in popup success callback:', e); + // Ensure puzzle is closed even on error + self.closePuzzle(); + } + return; + } + + + // Check if we're in a popup (DefaultPopup) - look for resend form + var resendForm = document.getElementById('verification_resend_otp_form'); + if (resendForm) { + // CRITICAL: Close puzzle before submitting form + self.closePuzzle(); + + // Add puzzle verification data to the resend form + var puzzleVerifiedInput = document.createElement('input'); + puzzleVerifiedInput.type = 'hidden'; + puzzleVerifiedInput.name = 'puzzle_verified'; + puzzleVerifiedInput.value = 'true'; + resendForm.appendChild(puzzleVerifiedInput); + + if (verificationData.puzzle_nonce) { + var nonceInput = document.createElement('input'); + nonceInput.type = 'hidden'; + nonceInput.name = 'mo_osp_puzzle_nonce'; + nonceInput.value = verificationData.puzzle_nonce; + resendForm.appendChild(nonceInput); + } + + if (verificationData.verification_token) { + var tokenInput = document.createElement('input'); + tokenInput.type = 'hidden'; + tokenInput.name = 'verification_token'; + tokenInput.value = verificationData.verification_token; + resendForm.appendChild(tokenInput); + } + + // Submit the resend form to trigger OTP sending + resendForm.submit(); + return; + } + + + // CRITICAL FIX: For popup context, show OTP form instead of reloading + // Check if we're in a popup (DefaultPopup was shown) + var $moPopup = $mo('#mo_site_otp_form, .mo_customer_validation-modal'); + if ($moPopup.length > 0) { + + // CRITICAL: Close puzzle before showing OTP popup again + self.closePuzzle(); + + // Show the popup again (it was hidden when puzzle was shown) + $mo('#mo_site_otp_form').show(); + $mo('.mo_customer_validation-modal').show(); + $mo('.mo-modal-backdrop').show(); + + // Update the message in popup to show OTP form message + var $moPopupBody = $mo('.mo_customer_validation-modal-body'); + if ($moPopupBody.length > 0) { + // Check if OTP form exists in popup + var $moOtpForm = $mo('#mo_validate_form'); + if ($moOtpForm.length > 0) { + // OTP form exists, trigger OTP sending via resend link + var $moResendLink = $mo('a.mo-resend, a[onclick*="mo_otp_verification_resend"]'); + if ($moResendLink.length > 0) { + // Add puzzle verification data to the OTP form first + var $moOtpFormInputs = $moOtpForm; + if (!$moOtpFormInputs.find('input[name="puzzle_verified"]').length) { + $moOtpFormInputs.append('<input type="hidden" name="puzzle_verified" value="true">'); + } + if (verificationData.puzzle_nonce && !$moOtpFormInputs.find('input[name="mo_osp_puzzle_nonce"]').length) { + $moOtpFormInputs.append('<input type="hidden" name="mo_osp_puzzle_nonce" value="' + verificationData.puzzle_nonce + '">'); + } + if (verificationData.verification_token && !$moOtpFormInputs.find('input[name="verification_token"]').length) { + $moOtpFormInputs.append('<input type="hidden" name="verification_token" value="' + verificationData.verification_token + '">'); + } + // Trigger resend to send OTP + $moResendLink.trigger('click'); + return; + } + } + } + } + + // Fallback: For regular forms, we need to resubmit the original form + // Since puzzle is verified, the next OTP request should succeed + + // Try to find the original login/submission form + var $moOriginalForm = $mo('form[name="loginform"], form#loginform, form.wp-login-form'); + if ($moOriginalForm.length === 0) { + // Try other common form selectors + $moOriginalForm = $mo('form').not('#mo_validate_form').not('#validation_goBack_form').not('#verification_resend_otp_form').first(); + } + + if ($moOriginalForm.length > 0) { + // Add puzzle verification data to the form + if (!$moOriginalForm.find('input[name="puzzle_verified"]').length) { + $moOriginalForm.append('<input type="hidden" name="puzzle_verified" value="true">'); + } + if (verificationData.puzzle_nonce && !$moOriginalForm.find('input[name="mo_osp_puzzle_nonce"]').length) { + $moOriginalForm.append('<input type="hidden" name="mo_osp_puzzle_nonce" value="' + verificationData.puzzle_nonce + '">'); + } + if (verificationData.verification_token && !$moOriginalForm.find('input[name="verification_token"]').length) { + $moOriginalForm.append('<input type="hidden" name="verification_token" value="' + verificationData.verification_token + '">'); + } + // Submit the form to trigger OTP sending + $moOriginalForm.submit(); + return; + } + + // Last resort: Reload the page + // Store puzzle completion flag in sessionStorage + sessionStorage.setItem('mo_osp_puzzle_completed', 'true'); + // Reload to show OTP form + window.location.reload(); + }, + + /** + * Click the Send OTP button after puzzle verification + */ + clickSendOTPButton: function() { + + // Find and click the send OTP button + var sendButton = this.findSendOTPButton(); + + if (sendButton && sendButton.length > 0) { + // Trigger OTP send directly + this.triggerOTPSendDirectly(); + } else { + // Reset the flag after a short delay to allow user to click manually + setTimeout(function() { + window.mo_otp_button_clicked = false; + }, 2000); + } + }, + + /** + * Trigger OTP send directly + */ + triggerOTPSendDirectly: function() { + + // Find the send OTP button + var sendButton = this.findSendOTPButton(); + + if (sendButton && sendButton.length > 0) { + + // Trigger the button click + sendButton.trigger('click'); + + } else { + // Show error message + var messageBox = $mo('#mo_message, .mo_message, [id*="mo_message"]').first(); + if (messageBox.length > 0) { + messageBox.empty().append('Error: Could not find Send OTP button.').css({ + 'color': '#ff5b5b', + 'background': '#ffefef', + 'padding': '10px', + 'border-radius': '5px' + }); + } else { + // Fallback: Create temporary message + $mo('body').append('<div id="mo_osp_temp_message" style="position: fixed; top: 20px; right: 20px; background: #ffefef; color: #ff5b5b; padding: 10px; border-radius: 5px; z-index: 9999;">Error: Could not find Send OTP button.</div>'); + setTimeout(function() { + $mo('#mo_osp_temp_message').remove(); + }, 5000); + } + } + }, + + /** + * Find the Send OTP button on the page + */ + findSendOTPButton: function() { + // Try multiple selectors to find the send OTP button + var buttonSelectors = [ + 'button#miniorange_wc_popup_send_otp_token', + '#mo_wc_send_otp', + 'input[id*="send_otp"]', + 'button[id*="send_otp"]', + 'input[value*="Send OTP"]', + 'button[value*="Send OTP"]', + 'input[id*="mo_wc_send_otp"]', + 'button[id*="mo_wc_send_otp"]', + '.mo-send-otp-button', + '[class*="send-otp"]', + 'input[name*="send_otp"]', + 'button[name*="send_otp"]' + ]; + + for (var i = 0; i < buttonSelectors.length; i++) { + var button = $mo(buttonSelectors[i]); + if (button.length > 0) { + return button.first(); + } + } + return null; + }, + + /** + * Intercept AJAX calls to add puzzle verification data + */ + interceptAjaxCalls: function() { + + var self = this; + var originalAjax = $mo.ajax; + + $mo.ajax = function(options) { + // Get verification data + var verificationData = self.lastVerificationResponse || {}; + + // Check for AJAX actions that might be OTP-related + var otpActions = [ + 'miniorange_ajax_otp', + 'mo_ajax_form_validate', + 'mo_send_otp', + 'mo_resend_otp', + 'woocommerce_checkout' + ]; + + var isOtpRelated = false; + if (options.data) { + var dataStr = typeof options.data === 'string' ? options.data : JSON.stringify(options.data); + for (var i = 0; i < otpActions.length; i++) { + if (dataStr.includes(otpActions[i]) || + dataStr.includes('send_otp') || dataStr.includes('verify_otp')) { + isOtpRelated = true; + break; + } + } + } + + if (isOtpRelated && window.mo_osp_puzzle_verified) { + + // Add puzzle verification data to AJAX request + if (typeof options.data === 'string') { + if (!options.data.includes('puzzle_verified')) { + options.data += '&puzzle_verified=true'; + } + if (verificationData.puzzle_nonce && !options.data.includes('mo_osp_puzzle_nonce')) { + options.data += '&mo_osp_puzzle_nonce=' + encodeURIComponent(verificationData.puzzle_nonce); + } + if (verificationData.verification_token && !options.data.includes('verification_token')) { + options.data += '&verification_token=' + encodeURIComponent(verificationData.verification_token); + } + if (window.MO_OSP_Puzzle.lastUserAnswer && !options.data.includes('puzzle_answer')) { + options.data += '&puzzle_answer=' + encodeURIComponent(window.MO_OSP_Puzzle.lastUserAnswer); + } + if (!options.data.includes('mo_osp_browser_id')) { + options.data += '&mo_osp_browser_id=' + encodeURIComponent(window.mo_osp_browser_id || ''); + } + } else { + // If data is null/undefined, create new data object with secure verification + options.data = options.data || {}; + options.data.mo_osp_browser_id = window.mo_osp_browser_id || ''; + + if (window.mo_osp_puzzle_verified) { + options.data.puzzle_verified = 'true'; + if (verificationData.puzzle_nonce) { + options.data.mo_osp_puzzle_nonce = verificationData.puzzle_nonce; + } + if (verificationData.verification_token) { + options.data.verification_token = verificationData.verification_token; + } + if (window.MO_OSP_Puzzle.lastUserAnswer) { + options.data.puzzle_answer = window.MO_OSP_Puzzle.lastUserAnswer; + } + // SECURITY: No longer sending puzzle question - server has it in session + } + } + + + // Wrap the success callback for universal handling + var originalSuccess = options.success; + options.success = function(response) { + + // Check if response contains puzzle requirement (multiple detection methods) + var isPuzzleRequired = false; + var detectionMethod = ''; + + // Method 1: Check structured response format + if (response && (response.puzzle_required === true || response.authType === 'PUZZLE_REQUIRED' || response.result === 'puzzle_required')) { + isPuzzleRequired = true; + detectionMethod = 'structured_response'; + } + // Method 2: Check message content (fallback) + else if (response && response.message && + response.message.includes('Please complete the security verification to continue')) { + isPuzzleRequired = true; + detectionMethod = 'message_content'; + } + + if (isPuzzleRequired) { + + // Call original success handler first + if (originalSuccess && typeof originalSuccess === 'function') { + originalSuccess.call(this, response); + } + + // Wait a moment for DOM to update, then show puzzle + setTimeout(function() { + if (typeof MO_OSP_Puzzle !== 'undefined' && !MO_OSP_Puzzle.isShowing) { + MO_OSP_Puzzle.showPuzzle({}); + } + }, 500); + + // Also trigger manual puzzle check as fallback + setTimeout(function() { + if (typeof MO_OSP_Puzzle !== 'undefined') { + MO_OSP_Puzzle.triggerPuzzleCheck(); + } + }, 1000); + return; + } + + // Call original success handler first - let it handle all UI updates + if (originalSuccess && typeof originalSuccess === 'function') { + originalSuccess.call(this, response); + } + }; + + // Also wrap error callback to catch puzzle requirements in error responses + var originalError = options.error; + options.error = function(xhr, status, error) { + + // Try to parse error response for puzzle requirements + try { + var errorResponse = JSON.parse(xhr.responseText); + if (errorResponse && (errorResponse.puzzle_required === true || errorResponse.authType === 'PUZZLE_REQUIRED' || errorResponse.result === 'puzzle_required')) { + + + // Show puzzle for error response + setTimeout(function() { + if (typeof MO_OSP_Puzzle !== 'undefined' && !MO_OSP_Puzzle.isShowing) { + MO_OSP_Puzzle.showPuzzle({}); + } + }, 500); + + // Don't call original error handler for puzzle requirements + return; + } + } catch (e) { + // Not JSON or parsing failed, continue with normal error handling + } + + // Call original error handler for non-puzzle errors + if (originalError && typeof originalError === 'function') { + originalError.call(this, xhr, status, error); + } + }; + } + + // Call the original ajax function + return originalAjax.call(this, options); + }; + + // Restore original AJAX after 10 seconds (longer for universal coverage) + setTimeout(function() { + $mo.ajax = originalAjax; + + }, 10000); + }, + + /** + * Manual trigger for puzzle when we know it should appear (after OTP request) + */ + triggerPuzzleCheck: function() { + + var self = this; + + // Reset counter for new check sequence + this.puzzleCheckCount = 0; + + // Check immediately + this.checkForPuzzleMessage(); + + // Only check a few more times with reasonable delays (not aggressive) + setTimeout(function() { self.checkForPuzzleMessage(); }, 500); + setTimeout(function() { self.checkForPuzzleMessage(); }, 1500); + }, + + /** + * Get email from form fields + */ + getEmailFromForm: function() { + var email = ''; + + // Try to get from visible email inputs first + $mo('input[type="email"]:visible, input[name*="email"]:visible, input[id*="email"]:visible').each(function() { + var $field = $mo(this); + var type = ($field.attr('type') || '').toLowerCase(); + if (type === 'button' || type === 'submit' || type === 'reset') { + return; + } + var value = $field.val(); + if (value && !/send\s+otp|verify\s+otp/i.test(value)) { + email = value; + return false; // Break loop + } + }); + + // Fallback to hidden inputs (from popup forms with extra_post_data) + if (!email) { + $mo('input[type="hidden"][name*="email"], input[type="hidden"][id*="email"]').each(function() { + if ($mo(this).val()) { + email = $mo(this).val(); + return false; // Break loop + } + }); + } + + + return email; + }, + + /** + * Get phone from form fields + */ + getPhoneFromForm: function() { + var phone = ''; + + // Try to get from visible phone inputs first + $mo('input[type="tel"]:visible, input[name*="phone"]:visible, input[id*="phone"]:visible, input[name*="mobile"]:visible').each(function() { + var $field = $mo(this); + var type = ($field.attr('type') || '').toLowerCase(); + if (type === 'button' || type === 'submit' || type === 'reset') { + return; + } + var value = $field.val(); + if (!value || /send\s+otp|verify\s+otp/i.test(value)) { + return; + } + // Normalize to digits/+ and require a minimum length to avoid tokens like "6ff2c895dc". + var normalized = String(value).replace(/[^0-9+]/g, ''); + var digitCount = normalized.replace(/\D/g, '').length; + if (digitCount >= 6) { + phone = normalized; + return false; // Break loop + } + }); + + // Fallback to hidden inputs (from popup forms with extra_post_data) + if (!phone) { + $mo('input[type="hidden"][name*="phone"], input[type="hidden"][id*="phone"], input[type="hidden"][name*="mobile"]').each(function() { + var value = $mo(this).val(); + if (!value || /send\s+otp|verify\s+otp/i.test(value)) { + return; + } + var normalized = String(value).replace(/[^0-9+]/g, ''); + var digitCount = normalized.replace(/\D/g, '').length; + if (digitCount >= 6) { + phone = normalized; + return false; // Break loop + } + }); + } + + + return phone; + }, + + /** + * Show error message in puzzle popup + */ + showError: function(message) { + $mo('#mo-osp-puzzle-error-text').text(message); + $mo('#mo-osp-puzzle-error').show(); + }, + + /** + * Hide error message in puzzle popup + */ + hideError: function() { + $mo('#mo-osp-puzzle-error').hide(); + } + }; + + // Initialize puzzle system when document is ready + $mo(document).ready(function() { + // Initialize puzzle system only when needed (not automatically on every page load) + // Puzzle will be initialized when OTP request triggers puzzle requirement + if (typeof MO_OSP_Puzzle !== 'undefined') { + // Initialize fully - bind events and mark as initialized + MO_OSP_Puzzle.init(); + MO_OSP_Puzzle.initialized = true; + } + }); + +})(jQuery); @@ -1,638 +1,638 @@ -/** - * OTP Spam Preventer Admin JavaScript - * - * @package miniorange-otp-verification/addons - */ - -(function($mo) { - 'use strict'; - - var MO_OSP_Admin = { - currentPage: 0, - pageSize: 50, - totalUsers: 0, - /** Auto-hide admin notices after this many ms (success / error). */ - noticeAutoDismissMs: 10000, - noticeDismissTimer: null, - - init: function() { - this.bindEvents(); - this.initializeAdvancedSettings(); - this.initializeBlockedUsers(); - }, - - bindEvents: function() { - var self = this; - - // Settings form validation - $mo(document).on('submit', '#mo_osp_settings_form', function(e) { - var isValid = self.validateSettings(); - if (!isValid) { - e.preventDefault(); - } - }); - - // Advanced settings toggle - $mo(document).on('click', '#mo-osp-toggle-advanced', function(e) { - e.preventDefault(); - self.toggleAdvancedSettings(); - }); - - // Real-time validation - $mo(document).on('input', '.mo-form-input', function() { - self.validateField($mo(this)); - - // Also validate cross-field relationships for relevant fields - var fieldId = $mo(this).attr('id'); - if (fieldId === 'mo_osp_max_attempts' || fieldId === 'mo_osp_hourly_limit' || fieldId === 'mo_osp_daily_limit') { - // Clear previous validation summary to avoid confusion - $mo('.mo-osp-validation-summary').remove(); - // Validate cross-field relationships - self.validateCrossFieldRelationships(); - } - }); - - // Clear auto-dismiss timer when user dismisses the notice (core adds .notice-dismiss after wp-notice-added). - $mo(document).on('click', '#mo-osp-admin-notice-container .notice-dismiss', function() { - if (self.noticeDismissTimer) { - clearTimeout(self.noticeDismissTimer); - self.noticeDismissTimer = null; - } - }); - - // Blocked users refresh - $mo(document).on('click', '#mo-osp-refresh-blocked-users', function() { - self.loadBlockedUsers(); - }); - - // Clear all blocked users / limits / puzzle flags - $mo(document).on('click', '#mo-osp-clear-all-blocked-users', function() { - self.clearAllBlockedUsers(); - }); - - // Addon enable/disable toggle - $mo(document).on('change', '#mo_osp_enabled', function() { - self.toggleAddonStatus($mo(this)); - }); - - // Unblock user - $mo(document).on('click', '.mo-osp-unblock-user', function() { - var identifierHash = $mo(this).data('hash'); - self.unblockUser(identifierHash); - }); - - // Pagination - $mo(document).on('click', '#mo-osp-prev-page', function() { - if (self.currentPage > 0) { - self.currentPage--; - self.loadBlockedUsers(); - } - }); - - $mo(document).on('click', '#mo-osp-next-page', function() { - var maxPage = Math.ceil(self.totalUsers / self.pageSize) - 1; - if (self.currentPage < maxPage) { - self.currentPage++; - self.loadBlockedUsers(); - } - }); - }, - - /** - * Initialize advanced settings state - */ - initializeAdvancedSettings: function() { - // Always start hidden by default, ignore localStorage for initial state - this.hideAdvancedSettings(); - }, - - /** - * Toggle advanced settings visibility - */ - toggleAdvancedSettings: function() { - var $moadvancedSection = $mo('#mo-osp-advanced-settings'); - var $motoggleButton = $mo('#mo-osp-toggle-advanced'); - var $motoggleText = $mo('#mo-osp-toggle-text'); - var $motoggleIcon = $mo('#mo-osp-toggle-icon'); - - if ($moadvancedSection.hasClass('mo-osp-advanced-hidden')) { - this.showAdvancedSettings(); - } else { - this.hideAdvancedSettings(); - } - }, - - /** - * Show advanced settings - */ - showAdvancedSettings: function() { - var $moadvancedSection = $mo('#mo-osp-advanced-settings'); - var $motoggleText = $mo('#mo-osp-toggle-text'); - var $motoggleIcon = $mo('#mo-osp-toggle-icon'); - - $moadvancedSection.removeClass('mo-osp-advanced-hidden').addClass('mo-osp-advanced-visible'); - $motoggleText.text('Hide Advanced'); - $motoggleIcon.addClass('rotate-180'); - - localStorage.setItem('mo_osp_advanced_expanded', 'true'); - }, - - /** - * Hide advanced settings - */ - hideAdvancedSettings: function() { - var $moadvancedSection = $mo('#mo-osp-advanced-settings'); - var $motoggleText = $mo('#mo-osp-toggle-text'); - var $motoggleIcon = $mo('#mo-osp-toggle-icon'); - - $moadvancedSection.removeClass('mo-osp-advanced-visible').addClass('mo-osp-advanced-hidden'); - $motoggleText.text('Show Advanced'); - $motoggleIcon.removeClass('rotate-180'); - - localStorage.setItem('mo_osp_advanced_expanded', 'false'); - }, - - /** - * Validate individual field - */ - validateField: function($mofield) { - var fieldId = $mofield.attr('id'); - var value = $mofield.val(); - var isValid = true; - var errorMessage = ''; - - // Remove existing error styling - $mofield.removeClass('mo-osp-error-field'); - $mofield.siblings('.mo-osp-validation-error').remove(); - - switch (fieldId) { - case 'mo_osp_cooldown_time': - var cooldownTime = parseInt(value); - if (isNaN(cooldownTime) || cooldownTime < 0 || cooldownTime > 86400) { - isValid = false; - errorMessage = 'Wait time must be between 0 and 86400 seconds (24 hours)'; - } - break; - - case 'mo_osp_max_attempts': - var maxAttempts = parseInt(value); - if (isNaN(maxAttempts) || maxAttempts < 3 || maxAttempts > 10) { - isValid = false; - errorMessage = 'Maximum attempts must be between 3 and 10'; - } - break; - - case 'mo_osp_block_time': - var blockTime = parseInt(value); - if (isNaN(blockTime) || blockTime < 60 || blockTime > 604800) { - isValid = false; - errorMessage = 'Block time must be between 60 seconds and 604800 seconds (7 days)'; - } - break; - - case 'mo_osp_daily_limit': - var dailyLimit = parseInt(value); - if (isNaN(dailyLimit) || dailyLimit < 1 || dailyLimit > 1000) { - isValid = false; - errorMessage = 'Daily limit must be between 1 and 1000'; - } - break; - - case 'mo_osp_hourly_limit': - var hourlyLimit = parseInt(value); - if (isNaN(hourlyLimit) || hourlyLimit < 1 || hourlyLimit > 100) { - isValid = false; - errorMessage = 'Hourly limit must be between 1 and 100'; - } - break; - } - - if (!isValid) { - $mofield.addClass('mo-osp-error-field'); - $mofield.parent().append('<span class="mo-osp-validation-error">' + errorMessage + '</span>'); - } - - return isValid; - }, - - /** - * Validate entire settings form - */ - validateSettings: function() { - var isValid = true; - var errors = []; - - // Clear previous errors - $mo('.mo-osp-error-field').removeClass('mo-osp-error-field'); - $mo('.mo-osp-validation-error').remove(); - - // Validate all form fields - var $mofields = $mo('#mo_osp_settings_form .mo-form-input'); - var self = this; - $mofields.each(function() { - if (!self.validateField($mo(this))) { - isValid = false; - } - }); - - // Validate cross-field relationships - if (isValid && !this.validateCrossFieldRelationships()) { - isValid = false; - } - - // Show summary if there are errors - if (!isValid && errors.length === 0) { - this.showValidationSummary('Please correct the highlighted fields before saving.'); - } - - return isValid; - }, - - /** - * Show validation summary - */ - showValidationSummary: function(message) { - // Remove existing summary - $mo('.mo-osp-validation-summary').remove(); - - // Add new summary - var $mosummary = $mo('<div class="mo-osp-validation-error mo-osp-validation-summary" style="margin-bottom: 20px; padding: 12px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 6px;">' + message + '</div>'); - $mo('#mo_osp_settings_form').prepend($mosummary); - - // Scroll to top - $mo('html, body').animate({ - scrollTop: $mosummary.offset().top - 100 - }, 500); - }, - - /** - * Validate cross-field relationships to ensure settings make logical sense - */ - validateCrossFieldRelationships: function() { - var maxAttempts = parseInt($mo('#mo_osp_max_attempts').val()) || 3; - var hourlyLimit = parseInt($mo('#mo_osp_hourly_limit').val()) || 5; - var dailyLimit = parseInt($mo('#mo_osp_daily_limit').val()) || 10; - - var errors = []; - - // Hourly limit must be greater than max attempts per window - if (hourlyLimit <= maxAttempts) { - errors.push('Hourly limit (' + hourlyLimit + ') must be greater than max attempts per window (' + maxAttempts + ')'); - $mo('#mo_osp_hourly_limit').addClass('mo-osp-error-field'); - } else { - $mo('#mo_osp_hourly_limit').removeClass('mo-osp-error-field'); - } - - // Daily limit must be greater than hourly limit - if (dailyLimit <= hourlyLimit) { - errors.push('Daily limit (' + dailyLimit + ') must be greater than hourly limit (' + hourlyLimit + ')'); - $mo('#mo_osp_daily_limit').addClass('mo-osp-error-field'); - } else { - $mo('#mo_osp_daily_limit').removeClass('mo-osp-error-field'); - } - - // Show cross-field validation errors - if (errors.length > 0) { - this.showValidationSummary('Settings validation failed: ' + errors.join('; ')); - return false; - } - - return true; - }, - - /** - * Initialize blocked users section - */ - initializeBlockedUsers: function() { - this.loadBlockedUsers(); - }, - - /** - * Load blocked users list - */ - loadBlockedUsers: function() { - var self = this; - var $mocontainer = $mo('#mo-osp-blocked-users-container'); - var $moloading = $mo('#mo-osp-blocked-users-loading'); - var $motbody = $mo('#mo-osp-blocked-users-tbody'); - - $moloading.show(); - $motbody.html(''); - - $mo.ajax({ - url: mo_osp_admin_ajax.ajax_url, - type: 'POST', - data: { - action: 'mo_osp_get_blocked_users', - security: mo_osp_admin_ajax.nonce, - limit: self.pageSize, - offset: self.currentPage * self.pageSize - }, - success: function(response) { - $moloading.hide(); - if (response.success && response.data && response.data.users) { - self.totalUsers = response.data.total || 0; - self.renderBlockedUsers(response.data.users); - self.updatePagination(); - } else { - $motbody.html('<tr><td colspan="4" class="mo-osp-no-data">' + - (response.data && response.data.message ? response.data.message : 'No blocked users found.') + - '</td></tr>'); - } - }, - error: function() { - $moloading.hide(); - $motbody.html('<tr><td colspan="4" class="mo-osp-error">Error loading blocked users. Please try again.</td></tr>'); - } - }); - }, - - /** - * Render blocked users in table - */ - renderBlockedUsers: function(users) { - var $motbody = $mo('#mo-osp-blocked-users-tbody'); - $motbody.empty(); - - if (users.length === 0) { - $motbody.html('<tr><td colspan="4" class="mo-osp-no-data">No blocked users found.</td></tr>'); - return; - } - - users.forEach(function(user) { - var row = '<tr data-hash="' + user.identifier_hash + '">' + - '<td><span class="mo-osp-identifier-type">' + user.identifier_type + '</span> ' + - '<span class="mo-osp-identifier-masked">' + user.identifier_masked + '</span></td>' + - '<td><span class="mo-osp-block-reason">' + user.block_reason_label + '</span></td>' + - '<td><span class="mo-osp-remaining-time" data-remaining="' + user.remaining_time + '">' + - user.remaining_time_formatted + '</span></td>' + - '<td><button type="button" class="mo-osp-unblock-user" ' + - 'data-hash="' + user.identifier_hash + '">' + - '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="vertical-align: middle;">' + - '<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z" fill="currentColor"/>' + - '</svg>' + - ' Unblock</button></td>' + - '</tr>'; - $motbody.append(row); - }); - - // Start countdown timers - this.startCountdownTimers(); - }, - - /** - * Start countdown timers for remaining time - */ - startCountdownTimers: function() { - var self = this; - $mo('.mo-osp-remaining-time').each(function() { - var $motime = $mo(this); - var remaining = parseInt($motime.data('remaining')) || 0; - - if (remaining > 0) { - var interval = setInterval(function() { - remaining--; - if (remaining <= 0) { - clearInterval(interval); - $motime.text('Expired'); - $motime.closest('tr').addClass('mo-osp-expired'); - } else { - $motime.text(self.formatTime(remaining)); - $motime.data('remaining', remaining); - } - }, 1000); - } - }); - }, - - /** - * Format time in seconds to human-readable format - */ - formatTime: function(seconds) { - if (seconds < 60) { - return seconds + 's'; - } else if (seconds < 3600) { - var minutes = Math.floor(seconds / 60); - var secs = seconds % 60; - return minutes + 'm ' + (secs > 0 ? secs + 's' : ''); - } else { - var hours = Math.floor(seconds / 3600); - var minutes = Math.floor((seconds % 3600) / 60); - return hours + 'h ' + (minutes > 0 ? minutes + 'm' : ''); - } - }, - - /** - * Update pagination controls - */ - updatePagination: function() { - var $mopagination = $mo('#mo-osp-blocked-users-pagination'); - var $moprev = $mo('#mo-osp-prev-page'); - var $monext = $mo('#mo-osp-next-page'); - var $moinfo = $mo('#mo-osp-page-info'); - - if (this.totalUsers === 0) { - $mopagination.hide(); - return; - } - - $mopagination.show(); - var maxPage = Math.ceil(this.totalUsers / this.pageSize) - 1; - var start = this.currentPage * this.pageSize + 1; - var end = Math.min((this.currentPage + 1) * this.pageSize, this.totalUsers); - - $moinfo.text('Showing ' + start + '-' + end + ' of ' + this.totalUsers); - $moprev.prop('disabled', this.currentPage === 0); - $monext.prop('disabled', this.currentPage >= maxPage); - }, - - /** - * Unblock a user - */ - /** - * Clear all block data (spam rows, rate limits, puzzle flags) - */ - clearAllBlockedUsers: function() { - var self = this; - var $mobtn = $mo('#mo-osp-clear-all-blocked-users'); - var originalHtml = $mobtn.html(); - - if (!window.confirm('This will remove all blocked users from the list, reset hourly/daily rate limits, and clear puzzle requirements stored by this addon. This cannot be undone. Continue?')) { - return; - } - - $mobtn.prop('disabled', true); - - $mo.ajax({ - url: mo_osp_admin_ajax.ajax_url, - type: 'POST', - data: { - action: 'mo_osp_clear_all_blocked_users', - security: mo_osp_admin_ajax.nonce - }, - success: function(response) { - var msg = response.data && response.data.message ? response.data.message : ''; - if (response.success && msg) { - self.currentPage = 0; - self.loadBlockedUsers(); - self.showAdminNotice(msg, 'success'); - } else if (msg) { - self.showAdminNotice(msg, 'error'); - } else { - self.showAdminNotice('Failed to clear data.', 'error'); - } - }, - error: function(xhr) { - var errMsg = 'Error clearing data. Please try again.'; - if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) { - errMsg = xhr.responseJSON.data.message; - } - self.showAdminNotice(errMsg, 'error'); - }, - complete: function() { - $mobtn.prop('disabled', false).html(originalHtml); - } - }); - }, - - unblockUser: function(identifierHash) { - var self = this; - var $mobutton = $mo('.mo-osp-unblock-user[data-hash="' + identifierHash + '"]'); - var originalText = $mobutton.text(); - - if (!confirm('Are you sure you want to unblock this user?')) { - return; - } - - $mobutton.prop('disabled', true).text('Unblocking...'); - - $mo.ajax({ - url: mo_osp_admin_ajax.ajax_url, - type: 'POST', - data: { - action: 'mo_osp_unblock_user_by_hash', - security: mo_osp_admin_ajax.nonce, - identifier_hash: identifierHash - }, - success: function(response) { - if (response.success) { - // Remove row or reload list - $mobutton.closest('tr').fadeOut(300, function() { - $mo(this).remove(); - self.loadBlockedUsers(); - }); - } else { - self.showAdminNotice( - response.data && response.data.message ? response.data.message : 'Failed to unblock user.', - 'error' - ); - $mobutton.prop('disabled', false).text(originalText); - } - }, - error: function(xhr) { - var errMsg = 'Error unblocking user. Please try again.'; - if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) { - errMsg = xhr.responseJSON.data.message; - } - self.showAdminNotice(errMsg, 'error'); - $mobutton.prop('disabled', false).text(originalText); - } - }); - }, - - /** - * Toggle addon enabled status via AJAX - */ - toggleAddonStatus: function($mocheckbox) { - var self = this; - var enabled = $mocheckbox.is(':checked') ? 1 : 0; - var previousState = !enabled; - - $mocheckbox.prop('disabled', true); - self.showAdminNotice('', ''); - - $mo.ajax({ - url: mo_osp_admin_ajax.ajax_url, - type: 'POST', - data: { - action: 'mo_osp_toggle_addon', - security: mo_osp_admin_ajax.nonce, - enabled: enabled - }, - success: function(response) { - if (response.success) { - var messageType = enabled ? 'success' : 'error'; - self.showAdminNotice(response.data && response.data.message ? response.data.message : 'Addon status updated.', messageType); - } else { - $mocheckbox.prop('checked', previousState); - self.showAdminNotice(response.data && response.data.message ? response.data.message : 'Failed to update addon status.', 'error'); - } - }, - error: function() { - $mocheckbox.prop('checked', previousState); - self.showAdminNotice('Error updating addon status. Please try again.', 'error'); - }, - complete: function() { - $mocheckbox.prop('disabled', false); - } - }); - }, - - /** - * Show admin notice message (auto-dismisses after noticeAutoDismissMs). - */ - showAdminNotice: function(message, type) { - var self = this; - var $mocontainer = $mo('#mo-osp-admin-notice-container'); - - if (this.noticeDismissTimer) { - clearTimeout(this.noticeDismissTimer); - this.noticeDismissTimer = null; - } - - $mocontainer.empty(); - - if (!message) { - return; - } - - var noticeClass = 'notice notice-warning'; - if (type === 'success') { - noticeClass = 'notice notice-success mo-notice-success'; - } else if (type === 'error') { - noticeClass = 'notice notice-error mo-notice-error'; - } - - var $monotice = $mo( - '<div class="' + noticeClass + ' is-dismissible mo-admin-notif" style="margin-top:1%;">' + - '<p>' + message + '</p>' + - '</div>' - ); - - $mocontainer.append($monotice); - $mo(document).trigger('wp-notice-added', [$monotice]); - - this.noticeDismissTimer = setTimeout(function() { - self.noticeDismissTimer = null; - if (!$monotice.length || !$monotice[0].ownerDocument.documentElement.contains($monotice[0])) { - return; - } - $monotice.fadeOut(300, function() { - $mo(this).remove(); - }); - }, this.noticeAutoDismissMs); - }, - - }; - - // Initialize when document is ready - $mo(document).ready(function() { - if (typeof mo_osp_admin_ajax !== 'undefined') { - MO_OSP_Admin.init(); - } - }); - - // Make it globally accessible for debugging - window.MO_OSP_Admin = MO_OSP_Admin; - +/** + * OTP Spam Preventer Admin JavaScript + * + * @package miniorange-otp-verification/addons + */ + +(function($mo) { + 'use strict'; + + var MO_OSP_Admin = { + currentPage: 0, + pageSize: 50, + totalUsers: 0, + /** Auto-hide admin notices after this many ms (success / error). */ + noticeAutoDismissMs: 10000, + noticeDismissTimer: null, + + init: function() { + this.bindEvents(); + this.initializeAdvancedSettings(); + this.initializeBlockedUsers(); + }, + + bindEvents: function() { + var self = this; + + // Settings form validation + $mo(document).on('submit', '#mo_osp_settings_form', function(e) { + var isValid = self.validateSettings(); + if (!isValid) { + e.preventDefault(); + } + }); + + // Advanced settings toggle + $mo(document).on('click', '#mo-osp-toggle-advanced', function(e) { + e.preventDefault(); + self.toggleAdvancedSettings(); + }); + + // Real-time validation + $mo(document).on('input', '.mo-form-input', function() { + self.validateField($mo(this)); + + // Also validate cross-field relationships for relevant fields + var fieldId = $mo(this).attr('id'); + if (fieldId === 'mo_osp_max_attempts' || fieldId === 'mo_osp_hourly_limit' || fieldId === 'mo_osp_daily_limit') { + // Clear previous validation summary to avoid confusion + $mo('.mo-osp-validation-summary').remove(); + // Validate cross-field relationships + self.validateCrossFieldRelationships(); + } + }); + + // Clear auto-dismiss timer when user dismisses the notice (core adds .notice-dismiss after wp-notice-added). + $mo(document).on('click', '#mo-osp-admin-notice-container .notice-dismiss', function() { + if (self.noticeDismissTimer) { + clearTimeout(self.noticeDismissTimer); + self.noticeDismissTimer = null; + } + }); + + // Blocked users refresh + $mo(document).on('click', '#mo-osp-refresh-blocked-users', function() { + self.loadBlockedUsers(); + }); + + // Clear all blocked users / limits / puzzle flags + $mo(document).on('click', '#mo-osp-clear-all-blocked-users', function() { + self.clearAllBlockedUsers(); + }); + + // Addon enable/disable toggle + $mo(document).on('change', '#mo_osp_enabled', function() { + self.toggleAddonStatus($mo(this)); + }); + + // Unblock user + $mo(document).on('click', '.mo-osp-unblock-user', function() { + var identifierHash = $mo(this).data('hash'); + self.unblockUser(identifierHash); + }); + + // Pagination + $mo(document).on('click', '#mo-osp-prev-page', function() { + if (self.currentPage > 0) { + self.currentPage--; + self.loadBlockedUsers(); + } + }); + + $mo(document).on('click', '#mo-osp-next-page', function() { + var maxPage = Math.ceil(self.totalUsers / self.pageSize) - 1; + if (self.currentPage < maxPage) { + self.currentPage++; + self.loadBlockedUsers(); + } + }); + }, + + /** + * Initialize advanced settings state + */ + initializeAdvancedSettings: function() { + // Always start hidden by default, ignore localStorage for initial state + this.hideAdvancedSettings(); + }, + + /** + * Toggle advanced settings visibility + */ + toggleAdvancedSettings: function() { + var $moadvancedSection = $mo('#mo-osp-advanced-settings'); + var $motoggleButton = $mo('#mo-osp-toggle-advanced'); + var $motoggleText = $mo('#mo-osp-toggle-text'); + var $motoggleIcon = $mo('#mo-osp-toggle-icon'); + + if ($moadvancedSection.hasClass('mo-osp-advanced-hidden')) { + this.showAdvancedSettings(); + } else { + this.hideAdvancedSettings(); + } + }, + + /** + * Show advanced settings + */ + showAdvancedSettings: function() { + var $moadvancedSection = $mo('#mo-osp-advanced-settings'); + var $motoggleText = $mo('#mo-osp-toggle-text'); + var $motoggleIcon = $mo('#mo-osp-toggle-icon'); + + $moadvancedSection.removeClass('mo-osp-advanced-hidden').addClass('mo-osp-advanced-visible'); + $motoggleText.text('Hide Advanced'); + $motoggleIcon.addClass('rotate-180'); + + localStorage.setItem('mo_osp_advanced_expanded', 'true'); + }, + + /** + * Hide advanced settings + */ + hideAdvancedSettings: function() { + var $moadvancedSection = $mo('#mo-osp-advanced-settings'); + var $motoggleText = $mo('#mo-osp-toggle-text'); + var $motoggleIcon = $mo('#mo-osp-toggle-icon'); + + $moadvancedSection.removeClass('mo-osp-advanced-visible').addClass('mo-osp-advanced-hidden'); + $motoggleText.text('Show Advanced'); + $motoggleIcon.removeClass('rotate-180'); + + localStorage.setItem('mo_osp_advanced_expanded', 'false'); + }, + + /** + * Validate individual field + */ + validateField: function($mofield) { + var fieldId = $mofield.attr('id'); + var value = $mofield.val(); + var isValid = true; + var errorMessage = ''; + + // Remove existing error styling + $mofield.removeClass('mo-osp-error-field'); + $mofield.siblings('.mo-osp-validation-error').remove(); + + switch (fieldId) { + case 'mo_osp_cooldown_time': + var cooldownTime = parseInt(value); + if (isNaN(cooldownTime) || cooldownTime < 0 || cooldownTime > 86400) { + isValid = false; + errorMessage = 'Wait time must be between 0 and 86400 seconds (24 hours)'; + } + break; + + case 'mo_osp_max_attempts': + var maxAttempts = parseInt(value); + if (isNaN(maxAttempts) || maxAttempts < 3 || maxAttempts > 10) { + isValid = false; + errorMessage = 'Maximum attempts must be between 3 and 10'; + } + break; + + case 'mo_osp_block_time': + var blockTime = parseInt(value); + if (isNaN(blockTime) || blockTime < 60 || blockTime > 604800) { + isValid = false; + errorMessage = 'Block time must be between 60 seconds and 604800 seconds (7 days)'; + } + break; + + case 'mo_osp_daily_limit': + var dailyLimit = parseInt(value); + if (isNaN(dailyLimit) || dailyLimit < 1 || dailyLimit > 1000) { + isValid = false; + errorMessage = 'Daily limit must be between 1 and 1000'; + } + break; + + case 'mo_osp_hourly_limit': + var hourlyLimit = parseInt(value); + if (isNaN(hourlyLimit) || hourlyLimit < 1 || hourlyLimit > 100) { + isValid = false; + errorMessage = 'Hourly limit must be between 1 and 100'; + } + break; + } + + if (!isValid) { + $mofield.addClass('mo-osp-error-field'); + $mofield.parent().append('<span class="mo-osp-validation-error">' + errorMessage + '</span>'); + } + + return isValid; + }, + + /** + * Validate entire settings form + */ + validateSettings: function() { + var isValid = true; + var errors = []; + + // Clear previous errors + $mo('.mo-osp-error-field').removeClass('mo-osp-error-field'); + $mo('.mo-osp-validation-error').remove(); + + // Validate all form fields + var $mofields = $mo('#mo_osp_settings_form .mo-form-input'); + var self = this; + $mofields.each(function() { + if (!self.validateField($mo(this))) { + isValid = false; + } + }); + + // Validate cross-field relationships + if (isValid && !this.validateCrossFieldRelationships()) { + isValid = false; + } + + // Show summary if there are errors + if (!isValid && errors.length === 0) { + this.showValidationSummary('Please correct the highlighted fields before saving.'); + } + + return isValid; + }, + + /** + * Show validation summary + */ + showValidationSummary: function(message) { + // Remove existing summary + $mo('.mo-osp-validation-summary').remove(); + + // Add new summary + var $mosummary = $mo('<div class="mo-osp-validation-error mo-osp-validation-summary" style="margin-bottom: 20px; padding: 12px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 6px;">' + message + '</div>'); + $mo('#mo_osp_settings_form').prepend($mosummary); + + // Scroll to top + $mo('html, body').animate({ + scrollTop: $mosummary.offset().top - 100 + }, 500); + }, + + /** + * Validate cross-field relationships to ensure settings make logical sense + */ + validateCrossFieldRelationships: function() { + var maxAttempts = parseInt($mo('#mo_osp_max_attempts').val()) || 3; + var hourlyLimit = parseInt($mo('#mo_osp_hourly_limit').val()) || 5; + var dailyLimit = parseInt($mo('#mo_osp_daily_limit').val()) || 10; + + var errors = []; + + // Hourly limit must be greater than max attempts per window + if (hourlyLimit <= maxAttempts) { + errors.push('Hourly limit (' + hourlyLimit + ') must be greater than max attempts per window (' + maxAttempts + ')'); + $mo('#mo_osp_hourly_limit').addClass('mo-osp-error-field'); + } else { + $mo('#mo_osp_hourly_limit').removeClass('mo-osp-error-field'); + } + + // Daily limit must be greater than hourly limit + if (dailyLimit <= hourlyLimit) { + errors.push('Daily limit (' + dailyLimit + ') must be greater than hourly limit (' + hourlyLimit + ')'); + $mo('#mo_osp_daily_limit').addClass('mo-osp-error-field'); + } else { + $mo('#mo_osp_daily_limit').removeClass('mo-osp-error-field'); + } + + // Show cross-field validation errors + if (errors.length > 0) { + this.showValidationSummary('Settings validation failed: ' + errors.join('; ')); + return false; + } + + return true; + }, + + /** + * Initialize blocked users section + */ + initializeBlockedUsers: function() { + this.loadBlockedUsers(); + }, + + /** + * Load blocked users list + */ + loadBlockedUsers: function() { + var self = this; + var $mocontainer = $mo('#mo-osp-blocked-users-container'); + var $moloading = $mo('#mo-osp-blocked-users-loading'); + var $motbody = $mo('#mo-osp-blocked-users-tbody'); + + $moloading.show(); + $motbody.html(''); + + $mo.ajax({ + url: mo_osp_admin_ajax.ajax_url, + type: 'POST', + data: { + action: 'mo_osp_get_blocked_users', + security: mo_osp_admin_ajax.nonce, + limit: self.pageSize, + offset: self.currentPage * self.pageSize + }, + success: function(response) { + $moloading.hide(); + if (response.success && response.data && response.data.users) { + self.totalUsers = response.data.total || 0; + self.renderBlockedUsers(response.data.users); + self.updatePagination(); + } else { + $motbody.html('<tr><td colspan="4" class="mo-osp-no-data">' + + (response.data && response.data.message ? response.data.message : 'No blocked users found.') + + '</td></tr>'); + } + }, + error: function() { + $moloading.hide(); + $motbody.html('<tr><td colspan="4" class="mo-osp-error">Error loading blocked users. Please try again.</td></tr>'); + } + }); + }, + + /** + * Render blocked users in table + */ + renderBlockedUsers: function(users) { + var $motbody = $mo('#mo-osp-blocked-users-tbody'); + $motbody.empty(); + + if (users.length === 0) { + $motbody.html('<tr><td colspan="4" class="mo-osp-no-data">No blocked users found.</td></tr>'); + return; + } + + users.forEach(function(user) { + var row = '<tr data-hash="' + user.identifier_hash + '">' + + '<td><span class="mo-osp-identifier-type">' + user.identifier_type + '</span> ' + + '<span class="mo-osp-identifier-masked">' + user.identifier_masked + '</span></td>' + + '<td><span class="mo-osp-block-reason">' + user.block_reason_label + '</span></td>' + + '<td><span class="mo-osp-remaining-time" data-remaining="' + user.remaining_time + '">' + + user.remaining_time_formatted + '</span></td>' + + '<td><button type="button" class="mo-osp-unblock-user" ' + + 'data-hash="' + user.identifier_hash + '">' + + '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="vertical-align: middle;">' + + '<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z" fill="currentColor"/>' + + '</svg>' + + ' Unblock</button></td>' + + '</tr>'; + $motbody.append(row); + }); + + // Start countdown timers + this.startCountdownTimers(); + }, + + /** + * Start countdown timers for remaining time + */ + startCountdownTimers: function() { + var self = this; + $mo('.mo-osp-remaining-time').each(function() { + var $motime = $mo(this); + var remaining = parseInt($motime.data('remaining')) || 0; + + if (remaining > 0) { + var interval = setInterval(function() { + remaining--; + if (remaining <= 0) { + clearInterval(interval); + $motime.text('Expired'); + $motime.closest('tr').addClass('mo-osp-expired'); + } else { + $motime.text(self.formatTime(remaining)); + $motime.data('remaining', remaining); + } + }, 1000); + } + }); + }, + + /** + * Format time in seconds to human-readable format + */ + formatTime: function(seconds) { + if (seconds < 60) { + return seconds + 's'; + } else if (seconds < 3600) { + var minutes = Math.floor(seconds / 60); + var secs = seconds % 60; + return minutes + 'm ' + (secs > 0 ? secs + 's' : ''); + } else { + var hours = Math.floor(seconds / 3600); + var minutes = Math.floor((seconds % 3600) / 60); + return hours + 'h ' + (minutes > 0 ? minutes + 'm' : ''); + } + }, + + /** + * Update pagination controls + */ + updatePagination: function() { + var $mopagination = $mo('#mo-osp-blocked-users-pagination'); + var $moprev = $mo('#mo-osp-prev-page'); + var $monext = $mo('#mo-osp-next-page'); + var $moinfo = $mo('#mo-osp-page-info'); + + if (this.totalUsers === 0) { + $mopagination.hide(); + return; + } + + $mopagination.show(); + var maxPage = Math.ceil(this.totalUsers / this.pageSize) - 1; + var start = this.currentPage * this.pageSize + 1; + var end = Math.min((this.currentPage + 1) * this.pageSize, this.totalUsers); + + $moinfo.text('Showing ' + start + '-' + end + ' of ' + this.totalUsers); + $moprev.prop('disabled', this.currentPage === 0); + $monext.prop('disabled', this.currentPage >= maxPage); + }, + + /** + * Unblock a user + */ + /** + * Clear all block data (spam rows, rate limits, puzzle flags) + */ + clearAllBlockedUsers: function() { + var self = this; + var $mobtn = $mo('#mo-osp-clear-all-blocked-users'); + var originalHtml = $mobtn.html(); + + if (!window.confirm('This will remove all blocked users from the list, reset hourly/daily rate limits, and clear puzzle requirements stored by this addon. This cannot be undone. Continue?')) { + return; + } + + $mobtn.prop('disabled', true); + + $mo.ajax({ + url: mo_osp_admin_ajax.ajax_url, + type: 'POST', + data: { + action: 'mo_osp_clear_all_blocked_users', + security: mo_osp_admin_ajax.nonce + }, + success: function(response) { + var msg = response.data && response.data.message ? response.data.message : ''; + if (response.success && msg) { + self.currentPage = 0; + self.loadBlockedUsers(); + self.showAdminNotice(msg, 'success'); + } else if (msg) { + self.showAdminNotice(msg, 'error'); + } else { + self.showAdminNotice('Failed to clear data.', 'error'); + } + }, + error: function(xhr) { + var errMsg = 'Error clearing data. Please try again.'; + if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) { + errMsg = xhr.responseJSON.data.message; + } + self.showAdminNotice(errMsg, 'error'); + }, + complete: function() { + $mobtn.prop('disabled', false).html(originalHtml); + } + }); + }, + + unblockUser: function(identifierHash) { + var self = this; + var $mobutton = $mo('.mo-osp-unblock-user[data-hash="' + identifierHash + '"]'); + var originalText = $mobutton.text(); + + if (!confirm('Are you sure you want to unblock this user?')) { + return; + } + + $mobutton.prop('disabled', true).text('Unblocking...'); + + $mo.ajax({ + url: mo_osp_admin_ajax.ajax_url, + type: 'POST', + data: { + action: 'mo_osp_unblock_user_by_hash', + security: mo_osp_admin_ajax.nonce, + identifier_hash: identifierHash + }, + success: function(response) { + if (response.success) { + // Remove row or reload list + $mobutton.closest('tr').fadeOut(300, function() { + $mo(this).remove(); + self.loadBlockedUsers(); + }); + } else { + self.showAdminNotice( + response.data && response.data.message ? response.data.message : 'Failed to unblock user.', + 'error' + ); + $mobutton.prop('disabled', false).text(originalText); + } + }, + error: function(xhr) { + var errMsg = 'Error unblocking user. Please try again.'; + if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) { + errMsg = xhr.responseJSON.data.message; + } + self.showAdminNotice(errMsg, 'error'); + $mobutton.prop('disabled', false).text(originalText); + } + }); + }, + + /** + * Toggle addon enabled status via AJAX + */ + toggleAddonStatus: function($mocheckbox) { + var self = this; + var enabled = $mocheckbox.is(':checked') ? 1 : 0; + var previousState = !enabled; + + $mocheckbox.prop('disabled', true); + self.showAdminNotice('', ''); + + $mo.ajax({ + url: mo_osp_admin_ajax.ajax_url, + type: 'POST', + data: { + action: 'mo_osp_toggle_addon', + security: mo_osp_admin_ajax.nonce, + enabled: enabled + }, + success: function(response) { + if (response.success) { + var messageType = enabled ? 'success' : 'error'; + self.showAdminNotice(response.data && response.data.message ? response.data.message : 'Addon status updated.', messageType); + } else { + $mocheckbox.prop('checked', previousState); + self.showAdminNotice(response.data && response.data.message ? response.data.message : 'Failed to update addon status.', 'error'); + } + }, + error: function() { + $mocheckbox.prop('checked', previousState); + self.showAdminNotice('Error updating addon status. Please try again.', 'error'); + }, + complete: function() { + $mocheckbox.prop('disabled', false); + } + }); + }, + + /** + * Show admin notice message (auto-dismisses after noticeAutoDismissMs). + */ + showAdminNotice: function(message, type) { + var self = this; + var $mocontainer = $mo('#mo-osp-admin-notice-container'); + + if (this.noticeDismissTimer) { + clearTimeout(this.noticeDismissTimer); + this.noticeDismissTimer = null; + } + + $mocontainer.empty(); + + if (!message) { + return; + } + + var noticeClass = 'notice notice-warning'; + if (type === 'success') { + noticeClass = 'notice notice-success mo-notice-success'; + } else if (type === 'error') { + noticeClass = 'notice notice-error mo-notice-error'; + } + + var $monotice = $mo( + '<div class="' + noticeClass + ' is-dismissible mo-admin-notif" style="margin-top:1%;">' + + '<p>' + message + '</p>' + + '</div>' + ); + + $mocontainer.append($monotice); + $mo(document).trigger('wp-notice-added', [$monotice]); + + this.noticeDismissTimer = setTimeout(function() { + self.noticeDismissTimer = null; + if (!$monotice.length || !$monotice[0].ownerDocument.documentElement.contains($monotice[0])) { + return; + } + $monotice.fadeOut(300, function() { + $mo(this).remove(); + }); + }, this.noticeAutoDismissMs); + }, + + }; + + // Initialize when document is ready + $mo(document).ready(function() { + if (typeof mo_osp_admin_ajax !== 'undefined') { + MO_OSP_Admin.init(); + } + }); + + // Make it globally accessible for debugging + window.MO_OSP_Admin = MO_OSP_Admin; + })(jQuery); \ No newline at end of file @@ -1,1846 +1,1846 @@ -/** - * Fixed OTP Spam Preventer - Proper Integration with Existing OTP Flow - * Based on resendcontrol addon patterns - */ - -(function($mo) { - 'use strict'; - - // Global variables - let activeTimers = []; - let isSpamPreventersInitialized = false; - let currentBrowserID = ''; - - // Button selectors (matching resendcontrol patterns) - const buttonSelectors = [ - 'input[value*="Send OTP"]', - 'input[value*="send otp"]', - 'input[value*="SEND OTP"]', - 'button:contains("Send OTP")', - 'button:contains("send otp")', - 'button:contains("SEND OTP")', - '#miniorange_otp_token_submit', - 'input[name="miniorange_otp_token_submit"]', - 'input[id*="send_otp"]', - 'input[class*="send_otp"]', - 'button[id*="send_otp"]', - 'button[class*="send_otp"]', - '#mo_wc_send_otp' - ]; - - /** - * WooCommerce block checkout: popup "send OTP" button id contains "send_otp" so it matches - * button[id*="send_otp"]. Do not hide or disable it — hide() stuck the button; disable() stuck - * it when AJAX errors / validation responses did not run our restore paths (user fixes form - * and cannot retry). Double-send is acceptable; server enforces limits. - */ - function isWcBlockCheckoutPopupSendButton($btn) { - if (!$btn || !$btn.length) { - return false; - } - if ($btn.attr('id') === 'miniorange_wc_popup_send_otp_token') { - return true; - } - return $btn.closest('#miniorange_wc_popup_send_otp_token').length > 0; - } - - function mospPrepareOtpButtonForRequest($mobutton) { - if (!$mobutton || !$mobutton.length) { - return; - } - if (isWcBlockCheckoutPopupSendButton($mobutton)) { - const $wcPrep = $mobutton.closest('#miniorange_wc_popup_send_otp_token'); - ($wcPrep.length ? $wcPrep : $mobutton).data('mo-osp-waiting-for-response', true); - return; - } - $mobutton.hide(); - $mobutton.data('mo-osp-waiting-for-response', true); - } - - function mospRestoreOtpButtonAfterRequest($btn) { - if (!$btn || !$btn.length) { - return; - } - if (isWcBlockCheckoutPopupSendButton($btn)) { - const $wc = $btn.closest('#miniorange_wc_popup_send_otp_token'); - if ($wc.length) { - $wc.prop('disabled', false).css('opacity', '').removeAttr('aria-busy').show(); - $wc.data('mo-osp-waiting-for-response', false); - } - } else { - $btn.show(); - $btn.data('mo-osp-waiting-for-response', false); - } - } - - /** - * Initialize spam preventer (following resendcontrol pattern) - */ - function initializeSpamPreventer() { - if (isSpamPreventersInitialized) { - return; - } - - // Initialize browser ID - initializeBrowserID(); - - // Wait for any OTP button to appear, then bind events - waitForAnyElement(buttonSelectors, function(matchingSelector) { - bindSpamPreventionEvents(); - - // Check if we should auto-trigger Send OTP after puzzle verification - checkAndAutoTriggerSendOTP(); - }); - - isSpamPreventersInitialized = true; - } - - /** - * Show message after puzzle completion and prompt user to resubmit - */ - function checkAndAutoTriggerSendOTP() { - const puzzleCompleted = sessionStorage.getItem('mo_osp_puzzle_completed'); - - if (puzzleCompleted === 'true') { - - // Clear the flag from sessionStorage - sessionStorage.removeItem('mo_osp_puzzle_completed'); - } - } - - /** - * Initialize browser ID for tracking - */ - function initializeBrowserID() { - currentBrowserID = localStorage.getItem('mo_osp_browser_id'); - - if (!currentBrowserID) { - currentBrowserID = generateBrowserID(); - localStorage.setItem('mo_osp_browser_id', currentBrowserID); - } - - // Make globally available - window.mo_osp_browser_id = currentBrowserID; - } - - /** - * Generate unique browser ID - */ - function generateBrowserID() { - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; - let result = ''; - for (let i = 0; i < 8; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return result; - } - - /** - * Wait for any element to appear (resendcontrol pattern) - */ - function waitForAnyElement(selectors, callback) { - const interval = setInterval(() => { - const matchingSelector = selectors.find(selector => $mo(selector).length > 0); - if (matchingSelector) { - clearInterval(interval); - callback(matchingSelector); - } - }, 100); - - setTimeout(() => { - clearInterval(interval); - }, 3000); - } - - /** - * Bind spam prevention events (following resendcontrol pattern) - */ - function bindSpamPreventionEvents() { - const messageSelector = 'div[id*="mo_message"]'; - - buttonSelectors.forEach(function(buttonSelector) { - $mo(buttonSelector).each(function() { - const $mobutton = $mo(this); - - // Prevent multiple bindings - if ($mobutton.data('spam-preventer-bound')) { - return; - } - $mobutton.data('spam-preventer-bound', true); - - $mobutton.on('click', function(e) { - - // CRITICAL: Skip spam prevention checks for external popup buttons - // External popup handles its own validation and error messages - // Check at click time since external popup may be dynamically loaded - const isExternalPopupButton = ($mobutton.attr('id') === 'send_otp' && - $mo('#mo_site_otp_form').length > 0) || - ($mo('#mo_site_otp_form').length > 0 && - $mo('.mo_customer_validation-modal').length > 0 && - ($mobutton.closest('#mo_site_otp_form').length > 0 || - $mobutton.closest('.mo_customer_validation-modal').length > 0)); - - if (isExternalPopupButton) { - // Don't prevent default or stop propagation - let external popup handle it - // Just return without doing anything - return; - } - - // Check if puzzle was just completed (skip puzzle check, allow OTP to proceed) - if (window.mo_osp_puzzle_just_completed || window.mo_osp_puzzle_verified) { - window.mo_osp_puzzle_just_completed = false; // Clear the flag - // Check cooldown even after puzzle completion - checkCooldownBeforeOTPSend($mobutton, messageSelector, e); - return; - } - - // IMPORTANT: Don't check puzzle requirement if we're currently verifying - // This prevents redundant AJAX calls during puzzle verification flow - if (window.MO_OSP_Puzzle && window.MO_OSP_Puzzle.isShowing) { - // Allow default behavior to continue - setupPostOTPHandling($mobutton, messageSelector); - return; - } - - // First check cooldown, then check puzzle requirement - checkCooldownBeforeOTPSend($mobutton, messageSelector, e).then(function(cooldownResult) { - if (cooldownResult.onCooldown) { - // Cooldown is active, show error message and prevent OTP send - e.preventDefault(); - e.stopImmediatePropagation(); - showCooldownError($mobutton, messageSelector, cooldownResult.remainingTime); - return false; - } else { - // No cooldown, check if puzzle is required - checkPuzzleRequirement().then(function(result) { - if (result.puzzleRequired) { - e.preventDefault(); - e.stopImmediatePropagation(); - showPuzzlePopup(); - return false; - } else { - setupPostOTPHandling($mobutton, messageSelector); - } - }).catch(function(error) { - setupPostOTPHandling($mobutton, messageSelector); - }); - } - }).catch(function(error) { - // On error, proceed with normal flow - checkPuzzleRequirement().then(function(result) { - if (result.puzzleRequired) { - e.preventDefault(); - e.stopImmediatePropagation(); - showPuzzlePopup(); - return false; - } else { - setupPostOTPHandling($mobutton, messageSelector); - } - }).catch(function(error2) { - setupPostOTPHandling($mobutton, messageSelector); - }); - }); - }); - }); - }); - } - - /** - * Check if cooldown is active before OTP send - */ - function checkCooldownBeforeOTPSend($mobutton, messageSelector, e) { - return new Promise((resolve, reject) => { - if (typeof mo_osp_ajax === 'undefined') { - resolve({ onCooldown: false }); - return; - } - - const email = getEmailFromForm(); - const phone = getPhoneFromForm(); - - $mo.ajax({ - url: mo_osp_ajax.ajax_url, - type: 'POST', - dataType: 'json', - data: { - action: 'mo_osp_check_blocked', - nonce: mo_osp_ajax.nonce, - mo_osp_browser_id: currentBrowserID, - email: email, - phone: phone - }, - success: function(response) { - // Handle WordPress JSON success wrapper - if (response && response.data) { - response = response.data; - } - - if (response && response.cooldown && response.remaining_time > 0) { - resolve({ - onCooldown: true, - remainingTime: parseInt(response.remaining_time) - }); - } else { - resolve({ onCooldown: false }); - } - }, - error: function(xhr, status, error) { - // If response is HTML (error page), treat as no cooldown to allow OTP send - if (xhr.responseText && xhr.responseText.trim().startsWith('<')) { - resolve({ onCooldown: false }); - return; - } - resolve({ onCooldown: false }); - } - }); - }); - } - - /** - * Show cooldown error message with timer - */ - function showCooldownError($mobutton, messageSelector, remainingTime) { - - // Find or create message element - let $momessageElem = $mo(messageSelector); - if ($momessageElem.length === 0) { - $momessageElem = $mo('div[id*="mo_message"], #mo_message, .mo_message').first(); - } - - // If still no message element, try to find the form and create one - if ($momessageElem.length === 0) { - const $form = $mobutton.closest('form'); - if ($form.length > 0) { - // Try to find existing message container or create one - $momessageElem = $form.find('[id*="mo_message"], .mo_message').first(); - if ($momessageElem.length === 0) { - // Create message element - $momessageElem = $mo('<div id="mo_message" style="display:block;"></div>'); - $mobutton.before($momessageElem); - } - } - } - - if ($momessageElem.length === 0) { - // Last resort: create a message element at the button's location - $momessageElem = $mo('<div id="mo_message" style="display:block;margin:10px 0;"></div>'); - $mobutton.before($momessageElem); - } - - - // Format the error message with timer using USER_IS_BLOCKED_AJAX format - // Message: "You have exceeded the limit to send OTP. Please wait for {minutes}:{seconds} minutes" - const minutes = Math.floor(remainingTime / 60); - const seconds = remainingTime % 60; - const formattedMinutes = String(minutes).padStart(2, '0'); - const formattedSeconds = String(seconds).padStart(2, '0'); - - // Use the USER_IS_BLOCKED_AJAX message format - const errorMessage = 'You have exceeded the limit to send OTP. Please wait for ' + - formattedMinutes + ':' + formattedSeconds + ' minutes'; - - - // Display the error message - $momessageElem.text(errorMessage); - mospApplyMoMessageErrorStyles($momessageElem); - if (!isWcCheckoutPopupMessageDisplay($momessageElem)) { - $momessageElem.css({ - 'padding': '10px', - 'border-radius': '4px', - 'margin': '10px 0' - }); - } - $momessageElem.show(); - - // Start the timer (use isBlocked=true for error messages) - startBlockTimer(remainingTime, $mobutton, $momessageElem, errorMessage); - } - - /** - * Show puzzle popup for AJAX forms when puzzle_required response is received - */ - function showPuzzleForAjaxForm(response) { - - // Check if puzzle popup HTML exists (should be added by mosp_add_puzzle_popup_to_frontend) - if ($mo('#mo-osp-puzzle-overlay').length === 0) { - console.error('[OSP] Puzzle overlay not found! Make sure puzzle popup HTML is added to frontend.'); - // Show error message to user - const messageElement = findMessageElement(); - if (messageElement && messageElement.length > 0) { - const $msgEl = $mo(messageElement); - $msgEl.text('Puzzle verification required but puzzle system is not loaded. Please refresh the page.'); - mospApplyMoMessageErrorStyles($msgEl); - $msgEl.show(); - } - return; - } - - // CRITICAL: Ensure puzzle overlay has higher z-index than WooCommerce checkout popup - // WooCommerce checkout popup uses z-index: 100000, so puzzle needs to be higher - var $puzzleOverlay = $mo('#mo-osp-puzzle-overlay'); - $puzzleOverlay.css('z-index', '100001'); - - // Show the puzzle popup - $mo('#mo-osp-puzzle-popup-outer-div').show().css('z-index', '100002'); - $puzzleOverlay.removeClass('mo-osp-hidden'); - - // Set up callback for when puzzle is completed - window.MO_OSP_Puzzle_onAjaxSuccess = function(verificationData) { - - // PRIORITY 1: Try to resubmit stored AJAX request if available - if (window.mo_osp_pending_ajax_request) { - const originalRequest = window.mo_osp_pending_ajax_request; - - // Add puzzle verification data to the request - let requestData = originalRequest.data; - - // Handle both string and object data formats - if (typeof requestData === 'string') { - // Parse query string and add puzzle data - const params = new URLSearchParams(requestData); - params.set('puzzle_verified', 'true'); - if (verificationData && verificationData.puzzle_nonce) { - params.set('mo_osp_puzzle_nonce', verificationData.puzzle_nonce); - } - if (verificationData && verificationData.verification_token) { - params.set('verification_token', verificationData.verification_token); - } - requestData = params.toString(); - } else if (typeof requestData === 'object') { - // Add puzzle data to object - requestData.puzzle_verified = 'true'; - if (verificationData && verificationData.puzzle_nonce) { - requestData.mo_osp_puzzle_nonce = verificationData.puzzle_nonce; - } - if (verificationData && verificationData.verification_token) { - requestData.verification_token = verificationData.verification_token; - } - } - - - // Resubmit the original AJAX request with puzzle verification data - $mo.ajax({ - url: originalRequest.url, - type: originalRequest.type, - data: requestData, - dataType: originalRequest.dataType, - crossDomain: originalRequest.crossDomain, - success: function(response) { - // Call original success callback if it exists - if (originalRequest.originalSuccess) { - originalRequest.originalSuccess.call(this, response); - } - }, - error: function(jqXHR, textStatus, errorThrown) { - console.error('[OSP] Resubmitted AJAX request failed:', textStatus, errorThrown); - // Call original error callback if it exists - if (originalRequest.originalError) { - originalRequest.originalError.call(this, jqXHR, textStatus, errorThrown); - } - } - }); - - // Clear stored request - delete window.mo_osp_pending_ajax_request; - return; - } - - // PRIORITY 2: Fallback to button click if no stored request - const $button = $mo(buttonSelectors.join(',')).filter(':visible').first(); - if ($button.length > 0) { - // Trigger the button click again to resubmit OTP request - // The puzzle_verified flag will be added by puzzle-system.js - $button.trigger('click'); - } else { - console.error('[OSP] Could not find OTP button to resubmit request'); - console.error('[OSP] Available buttons:', $mo(buttonSelectors.join(',')).length); - console.error('[OSP] Button selectors:', buttonSelectors); - - // Last resort: Try to find any form and submit it - const $forms = $mo('form').not('#mo_validate_form').not('#validation_goBack_form').not('#verification_resend_otp_form'); - if ($forms.length > 0) { - const $form = $forms.first(); - - // Add puzzle verification data - if (!$form.find('input[name="puzzle_verified"]').length) { - $form.append('<input type="hidden" name="puzzle_verified" value="true">'); - } - if (verificationData && verificationData.puzzle_nonce && !$form.find('input[name="mo_osp_puzzle_nonce"]').length) { - $form.append('<input type="hidden" name="mo_osp_puzzle_nonce" value="' + verificationData.puzzle_nonce + '">'); - } - if (verificationData && verificationData.verification_token && !$form.find('input[name="verification_token"]').length) { - $form.append('<input type="hidden" name="verification_token" value="' + verificationData.verification_token + '">'); - } - - $form.submit(); - } else { - console.error('[OSP] No form found either. User may need to manually resubmit.'); - } - } - }; - - // Initialize and show puzzle if system is available - if (typeof window.MO_OSP_Puzzle !== 'undefined') { - if (typeof window.MO_OSP_Puzzle.init === 'function' && !window.MO_OSP_Puzzle.initialized) { - window.MO_OSP_Puzzle.init(); - window.MO_OSP_Puzzle.initialized = true; - } - window.MO_OSP_Puzzle.showPuzzle({}); - } else { - console.error('[OSP] MO_OSP_Puzzle not available yet, waiting...'); - // Wait for puzzle system to load - setTimeout(function() { - if (typeof window.MO_OSP_Puzzle !== 'undefined') { - if (typeof window.MO_OSP_Puzzle.init === 'function' && !window.MO_OSP_Puzzle.initialized) { - window.MO_OSP_Puzzle.init(); - window.MO_OSP_Puzzle.initialized = true; - } - window.MO_OSP_Puzzle.showPuzzle({}); - } else { - console.error('[OSP] MO_OSP_Puzzle still not available after wait'); - const messageElement = findMessageElement(); - if (messageElement && messageElement.length > 0) { - const $msgEl = $mo(messageElement); - $msgEl.text('Puzzle verification required but puzzle system failed to load. Please refresh the page.'); - mospApplyMoMessageErrorStyles($msgEl); - $msgEl.show(); - } - } - }, 500); - } - } - - /** - * Check if puzzle is required before OTP send - */ - function checkPuzzleRequirement() { - return new Promise((resolve, reject) => { - if (typeof mo_osp_ajax === 'undefined') { - resolve({ puzzleRequired: false }); - return; - } - - $mo.ajax({ - url: mo_osp_ajax.ajax_url, - type: 'POST', - data: { - action: 'mo_osp_check_puzzle_requirement', - nonce: mo_osp_ajax.nonce, - mo_osp_browser_id: currentBrowserID, - email: getEmailFromForm(), - phone: getPhoneFromForm() - }, - success: function(response) { - if (response.success && response.data) { - const puzzleRequired = response.data.puzzle_required === true; - resolve({ puzzleRequired: puzzleRequired }); - } else { - resolve({ puzzleRequired: false }); - } - }, - error: function() { - reject(new Error('Failed to check puzzle requirement')); - } - }); - }); - } - - /** - * Setup post-OTP handling - intercept AJAX responses and add timers - */ - function setupPostOTPHandling($mobutton, messageSelector) { - mospPrepareOtpButtonForRequest($mobutton); - } - - /** - * Intercept AJAX responses to add timers to messages - * This is called globally for all AJAX responses - */ - function interceptAjaxResponse(response, messageElement) { - - if (!response) { - return; - } - - // PRIORITY 0: Handle puzzle_required response for AJAX forms - if (response.result === 'puzzle_required' || response.puzzle_required === true || response.authType === 'PUZZLE_REQUIRED') { - showPuzzleForAjaxForm(response); - return; - } - - if (!response.message) { - return; - } - - const messageText = response.message; - const isSuccess = response.result === 'success' || response.result === 'SUCCESS'; - const isError = response.result === 'error' || response.result === 'ERROR'; - - // Find message element if not provided - if (!messageElement) { - messageElement = findMessageElement(); - } - - if (!messageElement || messageElement.length === 0) { - // Try again after a short delay - setTimeout(function() { - interceptAjaxResponse(response, null); - }, 200); - return; - } - - - // PRIORITY 1: Handle error/blocked responses with timer (cooldown/block) - // This should override any existing success messages - // Check for error response OR error message in text - const isBlockedError = (isError && response.blocked === true && response.remaining_time > 0) || - (messageText.includes('exceeded') && messageText.includes('limit') && messageText.match(/\d+:\d+/)); - - if (isBlockedError) { - const $messageElement = $mo(messageElement); - - // Extract remaining time from response or message text - let remainingTime = 0; - if (response.remaining_time && response.remaining_time > 0) { - remainingTime = response.remaining_time; - } else { - // Try to extract from message text - remainingTime = extractTimerFromMessage(messageText); - } - - if (remainingTime <= 0) { - return; - } - - // Stop any existing timers for this element - if ($messageElement.data('mo-osp-timer-active')) { - // Clear all active timers - activeTimers.forEach(function(timer) { - clearInterval(timer); - }); - activeTimers = []; - } - - // Clear any existing timer flags to allow replacement - $messageElement.data('mo-osp-timer-added', false); - $messageElement.data('mo-osp-timer-active', false); - - // Format the error message with timer - const minutes = Math.floor(remainingTime / 60); - const seconds = remainingTime % 60; - const formattedMinutes = String(minutes).padStart(2, '0'); - const formattedSeconds = String(seconds).padStart(2, '0'); - - // Use the message from response, or format it if it has placeholders - let errorMessage = messageText; - if (errorMessage.includes('{minutes}') || errorMessage.includes('{seconds}')) { - errorMessage = errorMessage.replace('{minutes}', formattedMinutes).replace('{seconds}', formattedSeconds); - } else if (!errorMessage.includes(formattedMinutes + ':' + formattedSeconds)) { - // If message doesn't have timer format, format it - if (errorMessage.includes('exceeded') && errorMessage.includes('limit')) { - // Extract the base message (before the timer) - const baseMessage = errorMessage.replace(/\d+:\d+\s*(?:minute|min)s?/i, '').trim(); - if (baseMessage.endsWith('Please wait for')) { - errorMessage = baseMessage.substring(0, baseMessage.lastIndexOf('Please wait for')).trim() + ' Please wait for ' + formattedMinutes + ':' + formattedSeconds + ' minutes'; - } else { - errorMessage = errorMessage.replace(/\d+:\d+\s*(?:minute|min)s?/i, formattedMinutes + ':' + formattedSeconds + ' minutes'); - } - } else { - errorMessage = messageText; - } - } - - // Replace the message content with error message (overwrite any success message) - $messageElement.text(errorMessage); - mospApplyMoMessageErrorStyles($messageElement); - $messageElement.show(); - - // Set a flag to prevent success message from overwriting this error - $messageElement.data('mo-osp-error-message', true); - - // Store error response for potential restoration if overwritten - window.mo_osp_last_error_response = { - blocked: true, - message: errorMessage, - remaining_time: remainingTime, - result: 'error' - }; - - // Start the timer (this will check for active timer, but we've cleared it) - const $button = findButtonForMessage(messageElement); - startBlockTimer(remainingTime, $button, messageElement, errorMessage); - return; - } - - // PRIORITY 2: Check if it's a blocked/error message with timer in text - if ((isError || messageText.includes('exceeded')) && messageText.includes('limit')) { - const totalSeconds = extractTimerFromMessage(messageText); - if (totalSeconds > 0) { - const $messageElement = $mo(messageElement); - // Clear any existing timer flags - $messageElement.data('mo-osp-timer-added', false); - $messageElement.data('mo-osp-timer-active', false); - // Update message styling for error - mospApplyMoMessageErrorStyles($messageElement); - const $button = findButtonForMessage(messageElement); - startBlockTimer(totalSeconds, $button, messageElement, messageText); - return; - } - } - - // Check if it's a success message (OTP actually sent — not mismatch/validation copy containing "OTP"/"sent") - // But don't process if we have an active error message - const $messageElement = $mo(messageElement); - if (isSuccess && mospMessageMatchesOtpSentResendTimerAllowlist(messageText)) { - // Check if we have an active error message - if so, don't overwrite it - if ($messageElement.data('mo-osp-error-message')) { - return; - } - - if (isWcCheckoutPopupMessageDisplay($messageElement)) { - neutralizeWcCheckoutPopupMessageStyle($messageElement); - } - - // CRITICAL: Skip cooldown check for external popup responses - // External popup handles its own success/error messages and shouldn't be overwritten - const isExternalPopup = ($messageElement.attr('id') === 'mo_message' && - $mo('#mo_site_otp_form').length > 0) || - ($mo('#mo_site_otp_form').length > 0 && - $mo('.mo_customer_validation-modal').length > 0); - - if (isExternalPopup) { - return; // Don't process external popup success messages - let external popup handle them - } - - // Get actual remaining cooldown time from backend - const email = getEmailFromForm(); - const phone = getPhoneFromForm(); - - if (typeof mo_osp_ajax !== 'undefined') { - $mo.ajax({ - url: mo_osp_ajax.ajax_url, - type: 'POST', - dataType: 'json', - data: { - action: 'mo_osp_check_blocked', - nonce: mo_osp_ajax.nonce, - mo_osp_browser_id: currentBrowserID, - email: email, - phone: phone - }, - success: function(cooldownResponse) { - // Handle WordPress JSON success wrapper - if (cooldownResponse && cooldownResponse.data) { - cooldownResponse = cooldownResponse.data; - } - - let cooldownTime = 0; - - // Check if user is on cooldown - if (cooldownResponse && cooldownResponse.cooldown && cooldownResponse.remaining_time > 0) { - cooldownTime = parseInt(cooldownResponse.remaining_time); - } else { - // If not on cooldown yet, use default cooldown time - cooldownTime = (typeof mo_osp_ajax !== 'undefined' && mo_osp_ajax.timer_time) - ? parseInt(mo_osp_ajax.timer_time) : 60; - } - - if (cooldownTime > 0) { - const $button = findButtonForMessage(messageElement); - startCooldownTimer(cooldownTime, $button, messageElement, messageText); - } else { - // No cooldown (likely whitelisted IP), show success message without timer - const $button = findButtonForMessage(messageElement); - if ($button && $button.length > 0) { - $button.show(); - } - // Update message to show success without timer text - if (messageElement && window.verifyOTPmessage) { - const $message = $mo(messageElement); - $message.text(window.verifyOTPmessage); - } - } - }, - error: function(xhr, status, error) { - // If response is HTML (error page), treat as no cooldown to allow OTP send - if (xhr.responseText && xhr.responseText.trim().startsWith('<')) { - // HTML response - likely an error page, skip timer - const $button = findButtonForMessage(messageElement); - if ($button && $button.length > 0) { - $button.show(); - } - return; - } - // Fallback to default cooldown time on error - const defaultTime = (typeof mo_osp_ajax !== 'undefined' && mo_osp_ajax.timer_time) - ? parseInt(mo_osp_ajax.timer_time) : 60; - const $button = findButtonForMessage(messageElement); - startCooldownTimer(defaultTime, $button, messageElement, messageText); - } - }); - } else { - // Fallback if mo_osp_ajax is not available - const defaultTime = 60; - const $button = findButtonForMessage(messageElement); - startCooldownTimer(defaultTime, $button, messageElement, messageText); - } - } - } - - /** - * Find message element using various selectors - */ - function findMessageElement() { - // Prefer WooCommerce checkout popup message so cooldown/timer targets the same node the user sees. - const $wcPopupMsg = $mo('#mo_message_wc_pop_up'); - if ($wcPopupMsg.length && $wcPopupMsg.is(':visible')) { - return $wcPopupMsg.first(); - } - - const selectors = [ - 'div[id*="mo_message"]', - '#mo_message', - '.mo_message', - '[id*="mo_message"]', - '[class*="mo_message"]' - ]; - - for (let i = 0; i < selectors.length; i++) { - const $elem = $mo(selectors[i]); - if ($elem.length > 0 && $elem.is(':visible')) { - return $elem.first(); - } - } - - return null; - } - - /** - * Find button associated with message element - */ - function findButtonForMessage($messageElement) { - if (!$messageElement || $messageElement.length === 0) { - return $mo(); - } - - // WC block checkout: message is #mo_message_wc_pop_up; do not use the first generic - // "Send OTP" control elsewhere in the checkout form (wrong target for hide/show/timer). - if (isWcCheckoutPopupMessageDisplay($messageElement)) { - const $wcBtn = $mo('button#miniorange_wc_popup_send_otp_token').first(); - if ($wcBtn.length > 0) { - return $wcBtn; - } - } - - // Try to find button near the message element - const $form = $messageElement.closest('form'); - if ($form.length > 0) { - for (let i = 0; i < buttonSelectors.length; i++) { - const $button = $form.find(buttonSelectors[i]); - if ($button.length > 0) { - return $button.first(); - } - } - } - - // Fallback: find any button with waiting flag - return $mo(buttonSelectors.join(', ')).filter(function() { - return $mo(this).data('mo-osp-waiting-for-response') === true; - }).first(); - } - - /** - * Extract timer from message text - */ - function extractTimerFromMessage(messageText) { - let totalSeconds = 0; - - // Pattern 1: MM:SS format (handle large numbers like 1430:57) - const timerMatch = messageText.match(/(\d{1,4}):(\d{2})/); - if (timerMatch) { - const minutes = parseInt(timerMatch[1]); - const seconds = parseInt(timerMatch[2]); - totalSeconds = (minutes * 60) + seconds; - return totalSeconds; - } - - // Pattern 2: "X minutes" format - const altTimerMatch = messageText.match(/(\d+)\s*minutes?/i); - if (altTimerMatch) { - totalSeconds = parseInt(altTimerMatch[1]) * 60; - return totalSeconds; - } - - // Pattern 3: "X seconds" format - const secTimerMatch = messageText.match(/(\d+)\s*seconds?/i); - if (secTimerMatch) { - totalSeconds = parseInt(secTimerMatch[1]); - return totalSeconds; - } - - return 0; - } - - /** - * Start cooldown timer (resendcontrol pattern) - */ - function startCooldownTimer(timeLeft, $mobutton, $momessageElem, message) { - - if ($momessageElem.length > 0) { - $momessageElem.show(); - - if (isWcCheckoutPopupMessageDisplay($momessageElem)) { - neutralizeWcCheckoutPopupMessageStyle($momessageElem); - } else { - // CRITICAL: Preserve success message styling (green background, dark text) - const bgColor = $momessageElem.css('background-color'); - const isSuccessMessage = bgColor && ( - bgColor === 'rgb(142, 237, 142)' || - bgColor === '#8eed8e' || - bgColor.indexOf('142, 237, 142') !== -1 || - $momessageElem.css('background-color').indexOf('8eed8e') !== -1 - ); - - if (isSuccessMessage) { - $momessageElem.css({ - 'color': '#464646', - 'background-color': '#8eed8e', - }); - } - } - } - - startTimer(timeLeft, $momessageElem[0], $mobutton, message, false); - } - - /** - * Start block timer (resendcontrol pattern) - */ - function startBlockTimer(timeLeft, $mobutton, $momessageElem, message) { - - if ($momessageElem.length > 0) { - $momessageElem.show(); - } - - startTimer(timeLeft, $momessageElem[0], $mobutton, message, true); - } - - function isWcCheckoutPopupMessageDisplay($display) { - const $d = $mo($display); - return $d.length && $d.attr('id') === 'mo_message_wc_pop_up'; - } - - /** - * WC block/classic checkout popup: success text should have no inline error/success colors. - */ - function neutralizeWcCheckoutPopupMessageStyle($display) { - const $d = $mo($display); - if (!isWcCheckoutPopupMessageDisplay($d)) { - return; - } - $d.removeAttr('style'); - $d.removeData('mo-osp-error-message'); - } - - /** - * Error styling: WC checkout popup has no pink background; other mo_message containers keep the alert bar. - */ - function mospApplyMoMessageErrorStyles($el) { - const $e = $mo($el); - if (!$e.length) { - return; - } - if (isWcCheckoutPopupMessageDisplay($e)) { - $e.css({ - 'background-color': 'transparent', - 'background': 'none', - 'color': '#ff5b5b' - }); - return; - } - $e.css({ - 'background-color': '#ffefef', - 'color': '#ff5b5b' - }); - } - - /** - * Check if message container is plugin-owned. - */ - function isPluginMessageContainer($display) { - if (!$display || $display.length === 0) { - return false; - } - if ($display.is('#mo_message, #mo_message_wc_pop_up, .mo_message')) { - return true; - } - const id = ($display.attr('id') || '').toLowerCase(); - if (id.indexOf('mo_message') !== -1) { - return true; - } - const className = ($display.attr('class') || '').toLowerCase(); - return className.indexOf('mo_message') !== -1; - } - - /** - * Generic timer function (optimized) - */ - function startTimer(duration, display, button, displayMessage, isBlocked) { - if (!display) { - return; - } - - // If duration is 0 or less, don't start timer (for whitelisted IPs or when no cooldown). - if (duration <= 0) { - const $display = $mo(display); - // Just show the message without timer - if (displayMessage && window.verifyOTPmessage) { - $display.text(window.verifyOTPmessage); - } else if (displayMessage) { - $display.text(displayMessage); - } - neutralizeWcCheckoutPopupMessageStyle($display); - return; - } - - const $display = $mo(display); - const shouldAppendTimer = isPluginMessageContainer($display); - - // Check if timer is already active - prevent duplicate timers - if ($display.data('mo-osp-timer-active')) { - return; - } - - // Mark as having timer to prevent duplicates - $display.data('mo-osp-timer-active', true); - - let timer = duration; - - // Update immediately - const minutes = String(Math.floor(timer / 60)).padStart(2, '0'); - const seconds = String(timer % 60).padStart(2, '0'); - const formattedMessage = formatTimerMessage(displayMessage, minutes, seconds, timer, isBlocked, shouldAppendTimer); - $display.text(formattedMessage); - - // CRITICAL: Set success styling AFTER text update to override any inline styles - if (!isBlocked) { - if (isWcCheckoutPopupMessageDisplay($display)) { - neutralizeWcCheckoutPopupMessageStyle($display); - } else { - const bgColor = $display.css('background-color'); - const isSuccessMessage = bgColor && ( - bgColor === 'rgb(142, 237, 142)' || - bgColor === '#8eed8e' || - bgColor.indexOf('142, 237, 142') !== -1 - ); - - if (isSuccessMessage) { - // Override inline styles to ensure correct success styling - $display.css({ - 'color': '#464646', // Dark green text for better readability on green background - 'background-color': '#8eed8e', // Green background - }); - } - } - } - - $display.show(); - - const timerFunction = setInterval(() => { - // Check if error message flag is set - if so, this timer should stop (error message takes priority) - if ($display.data('mo-osp-error-message') && !isBlocked) { - clearInterval(timerFunction); - const index = activeTimers.indexOf(timerFunction); - if (index > -1) { - activeTimers.splice(index, 1); - } - return; - } - - // If verification success/failure text is now shown, stop resend timer so - // it cannot overwrite this message with old OTP-sent cooldown content. - const liveText = mospNormalizeMessageText($display.text() || ''); - if (!isBlocked && liveText) { - const liveBaseText = liveText.replace(/\s*you can send the next otp after\s+\d{1,2}:\d{2}\.?/gi, '').trim(); - const hasOutcomeMessage = mospIsOtpVerificationOutcomeMessage(liveBaseText) || mospIsNegativeOtpFeedbackMessage(liveBaseText); - const isOtpSentCopy = mospMessageMatchesOtpSentResendTimerAllowlist(liveBaseText); - if (hasOutcomeMessage && !isOtpSentCopy) { - clearInterval(timerFunction); - $display.data('mo-osp-timer-active', false); - $display.data('mo-osp-timer-added', false); - const activeIndex = activeTimers.indexOf(timerFunction); - if (activeIndex > -1) { - activeTimers.splice(activeIndex, 1); - } - return; - } - } - - timer--; - - if (timer < 0) { - clearInterval(timerFunction); - - // Remove timer flag - $display.data('mo-osp-timer-active', false); - $display.data('mo-osp-timer-added', false); - $display.data('mo-osp-error-message', false); - - neutralizeWcCheckoutPopupMessageStyle($display); - - // WC checkout popup + success resend cooldown: keep OTP-sent text visible (strip countdown only). - const domEl = display && display.nodeType === 1 ? display : null; - const isWcPopupTarget = !isBlocked && ( - isWcCheckoutPopupMessageDisplay($display) || - (domEl && domEl.id === 'mo_message_wc_pop_up') - ); - if (isWcPopupTarget) { - const stripResendLine = function (t) { - if (!t || typeof t !== 'string') { - return ''; - } - return t.replace(/\s*You can send the next OTP after\s+\d{1,2}:\d{2}\.?/gi, '').trim(); - }; - let baseMsg = stripResendLine(typeof displayMessage === 'string' ? mospStripHtml(displayMessage) : ''); - if (!baseMsg && window.verifyOTPmessage) { - baseMsg = stripResendLine(mospStripHtml(String(window.verifyOTPmessage))); - } - if (!baseMsg) { - baseMsg = stripResendLine($display.text() || ''); - } - $display.text(baseMsg); - $display.show(); - } else { - $display.hide(); - } - if (button && button.length > 0) { - mospRestoreOtpButtonAfterRequest($mo(button)); - } - - // Remove from active timers - const index = activeTimers.indexOf(timerFunction); - if (index > -1) { - activeTimers.splice(index, 1); - } - return; - } - - const minutes = String(Math.floor(timer / 60)).padStart(2, '0'); - const seconds = String(timer % 60).padStart(2, '0'); - const formattedMessage = formatTimerMessage(displayMessage, minutes, seconds, timer, isBlocked, shouldAppendTimer); - - // Always update the message text - // For error messages (isBlocked=true), we need to update the countdown - // For success messages, we also need to update the countdown - $display.text(formattedMessage); - - // CRITICAL: Set success styling AFTER text update to override any inline styles - if (!isBlocked) { - if (isWcCheckoutPopupMessageDisplay($display)) { - neutralizeWcCheckoutPopupMessageStyle($display); - } else { - const bgColor = $display.css('background-color'); - const isSuccessMessage = bgColor && ( - bgColor === 'rgb(142, 237, 142)' || - bgColor === '#8eed8e' || - bgColor.indexOf('142, 237, 142') !== -1 - ); - - if (isSuccessMessage) { - // Override inline styles to ensure correct success styling - $display.css({ - 'color': '#464646', // Dark green text for readability - 'background-color': '#8eed8e', // Green background - }); - } - } - } - }, 1000); - - activeTimers.push(timerFunction); - } - - /** - * Plain text from HTML (e.g. verifyOTPmessage may contain markup). - */ - function mospStripHtml(str) { - if (!str || typeof str !== 'string') { - return ''; - } - const tmp = document.createElement('div'); - tmp.innerHTML = str; - return (tmp.textContent || tmp.innerText || '').trim(); - } - - /** - * Normalize text for message classification (NBSP, trim). - */ - function mospNormalizeMessageText(text) { - if (!text || typeof text !== 'string') { - return ''; - } - return text.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim(); - } - - /** - * Validation / mismatch / failure copy — never append resend countdown to these. - */ - function mospIsNegativeOtpFeedbackMessage(text) { - if (!text || typeof text !== 'string') { - return false; - } - const t = mospNormalizeMessageText(text).toLowerCase(); - // Mismatch / comparison errors often contain "OTP" and "sent" but are not send-success (e.g. WC phone mismatch). - if (/\b(do\s+not|does\s+not|did\s+not|don'?t)\s+match\b/.test(t)) { - return true; - } - if (/\bnot\s+match\b/.test(t) && /\b(?:otp|phone|email|number|verification|code|contact|submission)\b/.test(t)) { - return true; - } - if (/\botp\b.*\bsent\b.*\bnot\s+match\b/.test(t) || /\bsent\b.*\botp\b.*\bnot\s+match\b/.test(t)) { - return true; - } - // Normal send-success copy includes "Please enter the OTP below" — not a validation failure. - if (/\b(?:otp|passcode|verification\s+code|sms\s+code|code)\s+has\s+been\s+sent\b/.test(t)) { - return false; - } - if (/\bhas\s+been\s+sent\s+to\b/.test(t)) { - return false; - } - return /\b(mismatch|invalid|incorrect|failed|failure|unsuccessful|wrong\s+(?:otp|code|number)|expired|verification\s+failed|not\s+verified|unable\s+to|could\s+not|must\s+enter|please\s+enter|is\s+required|are\s+required|\berror\b|exceeded\s+the\s+limit|try\s+again)\b/i.test(t); - } - - /** - * OTP verification outcome copy (success/failure). If this appears while resend timer - * is running, timer must stop and preserve this message. - */ - function mospIsOtpVerificationOutcomeMessage(text) { - if (!text || typeof text !== 'string') { - return false; - } - const t = mospNormalizeMessageText(text).toLowerCase(); - if (!t) { - return false; - } - if (t === 'success' || t === 'otp verified' || t === 'otp verification successful') { - return true; - } - return /\b(otp|one time passcode|verification\s+code|code)\b.*\b(verified|validated|successful|successfully)\b/.test(t) || - /\b(verified|validated|successful|successfully)\b.*\b(otp|one time passcode|verification\s+code|code)\b/.test(t); - } - - /** - * Messages that may receive the client line: "You can send the next OTP after MM:SS." - * Mirrors default English strings from MoMessages (OTP_SENT_PHONE, OTP_SENT_EMAIL, OTP_SENT, SMS_SENT_SUCCESS). - * Customized admin messages that change wording will not match until they keep the same opening/closing phrases. - * - * Not included: LIMIT_OTP_SENT / USER_IS_BLOCKED_* (server already supplies cooldown text); - * CHOOSE_METHOD / mismatch / error strings. - */ - function mospMessageMatchesOtpSentResendTimerAllowlist(text) { - if (!text || typeof text !== 'string') { - return false; - } - if (mospIsNegativeOtpFeedbackMessage(text)) { - return false; - } - const plain = mospStripHtml(String(text)); - let base = mospNormalizeMessageText(plain).replace(/\s+/g, ' ').trim().toLowerCase(); - base = base.replace(/\s*you can send the next otp after\s+\d{1,2}:\d{2}\.?/gi, '').trim(); - - if (/click\s+here\s+to\s+send\s+otp|send\s+otp\s+to\s+continue/i.test(base)) { - return false; - } - - // MoMessages::OTP_SENT_PHONE — "A OTP (One Time Passcode) has been sent to … Please enter the OTP in the field below to verify your phone." - if (base.indexOf('a otp (one time passcode) has been sent to ') === 0 && - base.indexOf('please enter the otp in the field below to verify your phone') !== -1) { - return true; - } - - // MoMessages::OTP_SENT_EMAIL — "A One Time Passcode has been sent to … Please enter the OTP below to verify your Email Address" - if (base.indexOf('a one time passcode has been sent to ') === 0 && - base.indexOf('please enter the otp below to verify your email address') !== -1) { - return true; - } - - // MoMessages::OTP_SENT — "A passcode has been sent to {{method}}. Please enter the otp below to verify your account." - if (base.indexOf('a passcode has been sent to ') === 0 && - base.indexOf('please enter the otp below to verify your account') !== -1) { - return true; - } - - // MoMessages::SMS_SENT_SUCCESS - if (base === 'sms was sent successfully.' || base === 'sms was sent successfully') { - return true; - } - - return false; - } - - /** - * Format timer message with countdown (consolidated logic) - */ - function formatTimerMessage(displayMessage, minutes, seconds, totalSeconds, isBlocked, shouldAppendTimer) { - const strippedDisplay = typeof displayMessage === 'string' - ? displayMessage.replace(/\s*You can send the next OTP after\s+\d{1,2}:\d{2}\.?/gi, '').trim() - : displayMessage; - let serverMessage = strippedDisplay || (isBlocked ? 'You are temporarily blocked.' : 'Please wait before requesting another OTP.'); - - // Never attach or keep the resend line on validation / mismatch messages. - if (!isBlocked && mospIsNegativeOtpFeedbackMessage(serverMessage)) { - return serverMessage; - } - - // Check if message has placeholder patterns - if (serverMessage.includes('{minutes}') && serverMessage.includes('{seconds}')) { - return serverMessage.replace('{minutes}', minutes).replace('{seconds}', seconds); - } - - // Check if message already contains timer information (avoid duplication) - if (serverMessage.match(/\d{1,4}:\d{2}\s*(minutes?|mins?)/i)) { - return serverMessage.replace(/\d{1,4}:\d{2}\s*(minutes?|mins?)/gi, `${minutes}:${seconds} minutes`); - } - - if (serverMessage.match(/\d+\s*(minutes?|mins?)/i)) { - const totalMinutes = Math.floor(totalSeconds / 60); - return serverMessage.replace(/\d+\s*(minutes?|mins?)/gi, `${totalMinutes} minutes`); - } - - if (serverMessage.match(/\d+\s*(seconds?|secs?)/i)) { - return serverMessage.replace(/\d+\s*(seconds?|secs?)/gi, `${totalSeconds} seconds`); - } - - // If totalSeconds is 0, don't add timer text (for whitelisted IPs or when no cooldown). - if (totalSeconds <= 0) { - if (!isBlocked && window.verifyOTPmessage) { - return window.verifyOTPmessage; - } - return serverMessage; - } - - // For cooldown messages, try to use window.verifyOTPmessage if available and no server message - if (!isBlocked && (!strippedDisplay || strippedDisplay.trim() === '') && window.verifyOTPmessage) { - if (!shouldAppendTimer) { - return window.verifyOTPmessage; - } - const verifyPlain = mospStripHtml(String(window.verifyOTPmessage)); - if (mospIsNegativeOtpFeedbackMessage(verifyPlain) || !mospMessageMatchesOtpSentResendTimerAllowlist(verifyPlain)) { - return window.verifyOTPmessage; - } - return window.verifyOTPmessage + ` You can send the next OTP after ${minutes}:${seconds}.`; - } - - // Fallback: add countdown to server message (only for allowlisted OTP-sent copy from MoMessages) - if (!isBlocked) { - if (!shouldAppendTimer || mospIsNegativeOtpFeedbackMessage(serverMessage) || !mospMessageMatchesOtpSentResendTimerAllowlist(serverMessage)) { - return serverMessage; - } - return `${serverMessage} You can send the next OTP after ${minutes}:${seconds}.`; - } - return `${serverMessage} (${minutes}:${seconds} remaining)`; - } - - /** - * Show puzzle popup - */ - function showPuzzlePopup() { - if (typeof MO_OSP_Puzzle !== 'undefined') { - // CRITICAL: Ensure puzzle overlay has higher z-index than WooCommerce checkout popup - // WooCommerce checkout popup uses z-index: 100000, so puzzle needs to be higher - var $puzzleOverlay = $mo('#mo-osp-puzzle-overlay'); - if ($puzzleOverlay.length > 0) { - $puzzleOverlay.css('z-index', '100001'); - } - var $puzzlePopup = $mo('#mo-osp-puzzle-popup-outer-div'); - if ($puzzlePopup.length > 0) { - $puzzlePopup.css('z-index', '100002'); - } - MO_OSP_Puzzle.showPuzzle({}); - } else { - alert('Security verification required. Please refresh the page.'); - } - } - - /** - * Clear all active timers - */ - function clearAllTimers() { - activeTimers.forEach(function(timer) { - clearInterval(timer); - }); - activeTimers = []; - } - - /** - * Get email from form fields (with phone fallback for consistency) - */ - function getEmailFromForm() { - let email = ''; - $mo('input[type="email"], input[name*="email"], input[id*="email"]').each(function() { - const $field = $mo(this); - const type = ($field.attr('type') || '').toLowerCase(); - if (type === 'button' || type === 'submit' || type === 'reset') { - return; - } - const value = $field.val(); - if (value && !/send\s+otp|verify\s+otp/i.test(value)) { - email = value; - return false; - } - }); - - // If no email found, use phone number as email for consistency - if (!email) { - const phone = getPhoneFromForm(); - if (phone) { - email = phone; - } - } - return email; - } - - /** - * Get phone from form fields - */ - function getPhoneFromForm() { - let phone = ''; - $mo('input[type="tel"], input[name*="phone"], input[id*="phone"], input[name*="mobile"]').each(function() { - const $field = $mo(this); - const type = ($field.attr('type') || '').toLowerCase(); - if (type === 'button' || type === 'submit' || type === 'reset') { - return; - } - const value = $field.val(); - if (!value || /send\s+otp|verify\s+otp/i.test(value)) { - return; - } - // Normalize to digits/+ and require a minimum length to avoid tokens like "6ff2c895dc". - const normalized = String(value).replace(/[^0-9+]/g, ''); - const digitCount = normalized.replace(/\D/g, '').length; - if (digitCount >= 6) { - phone = normalized; - return false; - } - }); - return phone; - } - - /** - * Handle puzzle success (called by puzzle system) - */ - window.MO_OSP_SpamPreventer_onPuzzleSuccess = function() { - clearAllTimers(); - - // Set flag to allow OTP to proceed after puzzle completion - window.mo_osp_skip_puzzle_check = true; - setTimeout(function() { - window.mo_osp_skip_puzzle_check = false; - }, 5000); - - // Clear any existing verifyOTPmessage - if (window.verifyOTPmessage) { - delete window.verifyOTPmessage; - } - - // Hide any existing messages (never hide WC checkout popup line — id contains substring "mo_message") - $mo('div[id*="mo_message"]').not('#mo_message_wc_pop_up').hide(); - - // Show all OTP buttons - buttonSelectors.forEach(function(selector) { - $mo(selector).each(function() { - mospRestoreOtpButtonAfterRequest($mo(this)); - }); - }); - }; - - /** - * Setup global AJAX response interceptor and message monitor - */ - function setupAjaxInterceptor() { - // Monitor for message elements that are added or updated - const messageObserver = new MutationObserver(function(mutations) { - mutations.forEach(function(mutation) { - // Check added nodes - mutation.addedNodes.forEach(function(node) { - if (node.nodeType === 1) { // Element node - const $node = $mo(node); - const $messageCandidate = $node.is('[id*="mo_message"], .mo_message, [class*="mo_message"]') - ? $node - : $node.find('[id*="mo_message"], .mo_message, [class*="mo_message"]').first(); - if ($messageCandidate.length > 0) { - checkAndAddTimerToMessage($messageCandidate); - } - } - }); - - // Check for text changes in existing message elements - if (mutation.type === 'childList' || mutation.type === 'characterData') { - const target = mutation.target; - if (target.nodeType === 1) { - const $target = $mo(target); - if ($target.is('[id*="mo_message"], .mo_message, [class*="mo_message"]') || - $target.find('[id*="mo_message"], .mo_message').length > 0) { - // Use immediate check (no setTimeout) to catch error messages before they're overwritten - const $msgElem = $target.is('[id*="mo_message"], .mo_message') ? $target : $target.find('[id*="mo_message"], .mo_message').first(); - if ($msgElem.length > 0) { - const currentText = $msgElem.text() || ''; - - // PRIORITY: Check if error message just appeared - // But only process if we haven't already processed this exact message - if (currentText.includes('exceeded') && currentText.includes('limit')) { - const lastProcessedError = $msgElem.data('mo-osp-last-processed-error'); - const timerActive = $msgElem.data('mo-osp-timer-active'); - if (lastProcessedError !== currentText || !timerActive) { - checkAndAddTimerToMessage($msgElem); - return; - } else { - // Already processed this error message and timer is active, skip to prevent loop - return; - } - } - - // Check if error message flag is set but message was overwritten with success - if ($msgElem.data('mo-osp-error-message')) { - // If error flag is set but message is success, restore error message - if (currentText.includes('sent') && currentText.includes('OTP') && !currentText.includes('exceeded')) { - // The error should have been set by interceptAjaxResponse, but if it was overwritten, - // we need to check if we have the error response stored - if (window.mo_osp_last_error_response && window.mo_osp_last_error_response.blocked) { - const errorResponse = window.mo_osp_last_error_response; - const minutes = Math.floor(errorResponse.remaining_time / 60); - const seconds = errorResponse.remaining_time % 60; - const formattedMinutes = String(minutes).padStart(2, '0'); - const formattedSeconds = String(seconds).padStart(2, '0'); - let errorMessage = errorResponse.message || 'You have exceeded the limit to send OTP. Please wait for ' + - formattedMinutes + ':' + formattedSeconds + ' minutes'; - if (errorMessage.includes('{minutes}') || errorMessage.includes('{seconds}')) { - errorMessage = errorMessage.replace('{minutes}', formattedMinutes).replace('{seconds}', formattedSeconds); - } - $msgElem.text(errorMessage); - mospApplyMoMessageErrorStyles($msgElem); - // Clear timer flags and restart timer - $msgElem.data('mo-osp-timer-active', false); - $msgElem.data('mo-osp-timer-added', false); - const $button = findButtonForMessage($msgElem); - startBlockTimer(errorResponse.remaining_time, $button, $msgElem, errorMessage); - return; - } - } - } - - // For other messages, use setTimeout to avoid too many checks - setTimeout(function() { - checkAndAddTimerToMessage($msgElem); - }, 100); - } - } - } - } - }); - }); - - // Start observing the document body for changes - if (document.body) { - messageObserver.observe(document.body, { - childList: true, - subtree: true, - characterData: true - }); - } - - // Also check existing messages periodically (fallback) - setInterval(function() { - const $messages = $mo('[id*="mo_message"], .mo_message'); - $messages.each(function() { - const $msg = $mo(this); - if ($msg.is(':visible') && !$msg.data('mo-osp-timer-added')) { - checkAndAddTimerToMessage($msg); - } - }); - }, 500); - - // Intercept jQuery AJAX responses and store request data for puzzle resubmission - const originalAjax = $mo.ajax; - $mo.ajax = function(options) { - const originalSuccess = options.success; - const originalError = options.error; - - // Check if this is an OTP-related request by examining URL or data - const isOtpRequest = (options.url && ( - options.url.indexOf('admin-ajax.php') !== -1 || - options.url.indexOf('otp') !== -1 || - options.url.indexOf('miniorange') !== -1 - )) || (options.data && ( - (typeof options.data === 'string' && (options.data.indexOf('otp') !== -1 || options.data.indexOf('miniorange') !== -1)) || - (typeof options.data === 'object' && (options.data.action && ( - options.data.action.indexOf('otp') !== -1 || - options.data.action.indexOf('miniorange') !== -1 || - options.data.action === 'mo_external_popup_option' - ))) - )); - - // Wrap success callback to check for puzzle_required - options.success = function(response, textStatus, jqXHR) { - // Check if this is an external popup request - const isExternalPopupRequest = options.data && ( - (typeof options.data === 'object' && options.data.action === 'mo_external_popup_option') || - (typeof options.data === 'string' && options.data.indexOf('mo_external_popup_option') !== -1) - ); - - // Check if puzzle is required - if so, store the request for resubmission - if (isOtpRequest && response && (response.result === 'puzzle_required' || response.puzzle_required === true || response.authType === 'PUZZLE_REQUIRED')) { - window.mo_osp_pending_ajax_request = { - url: options.url, - type: options.type || 'POST', - data: typeof options.data === 'string' ? options.data : (options.data ? JSON.parse(JSON.stringify(options.data)) : {}), - dataType: options.dataType || 'json', - crossDomain: options.crossDomain || false, - originalSuccess: originalSuccess, - originalError: originalError - }; - } - - // CRITICAL: Skip interceptAjaxResponse for external popup success responses - // External popup handles its own success/error messages and shouldn't be overwritten - if (isExternalPopupRequest && response && response.result === 'success') { - // Call original success callback without intercepting - if (originalSuccess) { - originalSuccess.apply(this, arguments); - } - return; - } - - // Call interceptAjaxResponse if response has message - if (response && (response.message || response.result)) { - interceptAjaxResponse(response, null); - } - - // Call original success callback - if (originalSuccess) { - originalSuccess.apply(this, arguments); - } - }; - - // Wrap error callback - options.error = function(jqXHR, textStatus, errorThrown) { - // Try to parse error response - try { - const response = jqXHR.responseJSON || JSON.parse(jqXHR.responseText); - - // Check if puzzle is required in error response - if (isOtpRequest && response && (response.result === 'puzzle_required' || response.puzzle_required === true || response.authType === 'PUZZLE_REQUIRED')) { - window.mo_osp_pending_ajax_request = { - url: options.url, - type: options.type || 'POST', - data: typeof options.data === 'string' ? options.data : (options.data ? JSON.parse(JSON.stringify(options.data)) : {}), - dataType: options.dataType || 'json', - crossDomain: options.crossDomain || false, - originalSuccess: originalSuccess, - originalError: originalError - }; - } - - if (response && (response.message || response.result)) { - interceptAjaxResponse(response, null); - } - } catch (e) { - // Ignore parse errors - } - - // Call original error callback - if (originalError) { - originalError.apply(this, arguments); - } - }; - - // Call original ajax - return originalAjax.apply(this, arguments); - }; - } - - /** - * Check message element and add timer if needed - */ - function checkAndAddTimerToMessage($messageElement) { - if (!$messageElement || $messageElement.length === 0) { - return; - } - - const messageSelector = '[id*="mo_message"], .mo_message, [class*="mo_message"]'; - // Only act on OTP message containers to avoid corrupting unrelated text - if (!$messageElement.is(messageSelector)) { - const $innerMessage = $messageElement.find(messageSelector).first(); - if ($innerMessage.length === 0) { - return; - } - $messageElement = $innerMessage; - } - const messageText = $messageElement.text() || ''; - - if (!messageText.trim()) { - return; - } - - // PRIORITY: Check if it's a blocked/error message with timer - // Error messages should always replace success messages, even if timer is already added - if (messageText.includes('exceeded') && messageText.includes('limit')) { - - const timerActive = $messageElement.data('mo-osp-timer-active'); - - // Check if we've already processed this exact error message to prevent infinite loops - const lastProcessedError = $messageElement.data('mo-osp-last-processed-error'); - if (lastProcessedError === messageText && timerActive) { - return; - } - - // CRITICAL: Set error flag FIRST, then stop timer - // This ensures that if the timer interval callback is already queued, it will see the flag and stop - $messageElement.data('mo-osp-error-message', true); - $messageElement.data('mo-osp-last-processed-error', messageText); - - // IMPORTANT: Set the error message text IMMEDIATELY - // This prevents the success timer (if still running) from overwriting it - // But only if the current text is different to avoid triggering unnecessary mutations - const currentText = $messageElement.text() || ''; - if (currentText !== messageText) { - $messageElement.text(messageText); - } - mospApplyMoMessageErrorStyles($messageElement); - $messageElement.show(); - - const totalSeconds = extractTimerFromMessage(messageText); - if (totalSeconds > 0) { - // Check if interceptAjaxResponse already handled this error message - // If the timer is active and the error flag is set, interceptAjaxResponse likely already started it - if (timerActive && $messageElement.data('mo-osp-error-message')) { - return; - } - - // CRITICAL: ALWAYS stop any active timer when error message is detected - // The success timer might be running and overwriting the error message - // We MUST stop it immediately, regardless of what the current text says - if (timerActive) { - // Clear all active timers (whether success or error) - activeTimers.forEach(function(timer) { - clearInterval(timer); - }); - activeTimers = []; - // Clear flags AFTER clearing timers to ensure clean state - $messageElement.data('mo-osp-timer-active', false); - $messageElement.data('mo-osp-timer-added', false); - } - - // Set timer-added flag (but NOT timer-active - let startTimer set that) - $messageElement.data('mo-osp-timer-added', true); - - // Store error response for potential restoration if overwritten - window.mo_osp_last_error_response = { - blocked: true, - message: messageText, - remaining_time: totalSeconds, - result: 'error' - }; - - const $button = findButtonForMessage($messageElement); - startBlockTimer(totalSeconds, $button, $messageElement, messageText); - return; - } - } - - // Success OTP-sent only (MutationObserver): same rules as interceptAjaxResponse — not generic "sent"/"OTP" - const looksLikeSendPrompt = /click\s+here\s+to\s+send\s+otp|send\s+otp/i.test(messageText); - if (mospMessageMatchesOtpSentResendTimerAllowlist(messageText) && !looksLikeSendPrompt && - !messageText.match(/\d{1,2}:\d{2}\s*(remaining|minutes?|mins?)/i)) { - - // Check if we have an active error message - if so, don't process success message - if ($messageElement.data('mo-osp-error-message')) { - return; - } - - if (isWcCheckoutPopupMessageDisplay($messageElement)) { - neutralizeWcCheckoutPopupMessageStyle($messageElement); - } - - // Skip if timer already added (only for success messages) - if ($messageElement.data('mo-osp-timer-added')) { - return; - } - $messageElement.data('mo-osp-timer-added', true); - - // Get actual remaining cooldown time from backend - const email = getEmailFromForm(); - const phone = getPhoneFromForm(); - - if (typeof mo_osp_ajax !== 'undefined') { - $mo.ajax({ - url: mo_osp_ajax.ajax_url, - type: 'POST', - dataType: 'json', - data: { - action: 'mo_osp_check_blocked', - nonce: mo_osp_ajax.nonce, - mo_osp_browser_id: currentBrowserID, - email: email, - phone: phone - }, - success: function(cooldownResponse) { - // Handle WordPress JSON success wrapper - if (cooldownResponse && cooldownResponse.data) { - cooldownResponse = cooldownResponse.data; - } - - let cooldownTime = 0; - if (cooldownResponse && cooldownResponse.cooldown && cooldownResponse.remaining_time > 0) { - cooldownTime = parseInt(cooldownResponse.remaining_time); - } else { - cooldownTime = (typeof mo_osp_ajax !== 'undefined' && mo_osp_ajax.timer_time) - ? parseInt(mo_osp_ajax.timer_time) : 60; - } - if (cooldownTime > 0) { - const $button = findButtonForMessage($messageElement); - startCooldownTimer(cooldownTime, $button, $messageElement, messageText); - } else { - // No cooldown, show button - const $button = findButtonForMessage($messageElement); - if ($button && $button.length > 0) { - mospRestoreOtpButtonAfterRequest($button); - } - $messageElement.data('mo-osp-timer-added', false); // Allow retry - } - }, - error: function(xhr, status, error) { - // If response is HTML (error page), treat as no cooldown to allow OTP send - if (xhr.responseText && xhr.responseText.trim().startsWith('<')) { - // HTML response - likely an error page, skip timer - const $button = findButtonForMessage($messageElement); - if ($button && $button.length > 0) { - mospRestoreOtpButtonAfterRequest($button); - } - $messageElement.data('mo-osp-timer-added', false); - return; - } - const defaultTime = (typeof mo_osp_ajax !== 'undefined' && mo_osp_ajax.timer_time) - ? parseInt(mo_osp_ajax.timer_time) : 60; - const $button = findButtonForMessage($messageElement); - startCooldownTimer(defaultTime, $button, $messageElement, messageText); - } - }); - } else { - // Fallback if mo_osp_ajax is not available - const defaultTime = 60; - const $button = findButtonForMessage($messageElement); - startCooldownTimer(defaultTime, $button, $messageElement, messageText); - } - } - } - - // Initialize when document is ready (resendcontrol pattern) - $mo(document).ready(function() { - setTimeout(function() { - initializeSpamPreventer(); - setupAjaxInterceptor(); - }, 100); - }); - -})(jQuery); +/** + * Fixed OTP Spam Preventer - Proper Integration with Existing OTP Flow + * Based on resendcontrol addon patterns + */ + +(function($mo) { + 'use strict'; + + // Global variables + let activeTimers = []; + let isSpamPreventersInitialized = false; + let currentBrowserID = ''; + + // Button selectors (matching resendcontrol patterns) + const buttonSelectors = [ + 'input[value*="Send OTP"]', + 'input[value*="send otp"]', + 'input[value*="SEND OTP"]', + 'button:contains("Send OTP")', + 'button:contains("send otp")', + 'button:contains("SEND OTP")', + '#miniorange_otp_token_submit', + 'input[name="miniorange_otp_token_submit"]', + 'input[id*="send_otp"]', + 'input[class*="send_otp"]', + 'button[id*="send_otp"]', + 'button[class*="send_otp"]', + '#mo_wc_send_otp' + ]; + + /** + * WooCommerce block checkout: popup "send OTP" button id contains "send_otp" so it matches + * button[id*="send_otp"]. Do not hide or disable it — hide() stuck the button; disable() stuck + * it when AJAX errors / validation responses did not run our restore paths (user fixes form + * and cannot retry). Double-send is acceptable; server enforces limits. + */ + function isWcBlockCheckoutPopupSendButton($btn) { + if (!$btn || !$btn.length) { + return false; + } + if ($btn.attr('id') === 'miniorange_wc_popup_send_otp_token') { + return true; + } + return $btn.closest('#miniorange_wc_popup_send_otp_token').length > 0; + } + + function mospPrepareOtpButtonForRequest($mobutton) { + if (!$mobutton || !$mobutton.length) { + return; + } + if (isWcBlockCheckoutPopupSendButton($mobutton)) { + const $wcPrep = $mobutton.closest('#miniorange_wc_popup_send_otp_token'); + ($wcPrep.length ? $wcPrep : $mobutton).data('mo-osp-waiting-for-response', true); + return; + } + $mobutton.hide(); + $mobutton.data('mo-osp-waiting-for-response', true); + } + + function mospRestoreOtpButtonAfterRequest($btn) { + if (!$btn || !$btn.length) { + return; + } + if (isWcBlockCheckoutPopupSendButton($btn)) { + const $wc = $btn.closest('#miniorange_wc_popup_send_otp_token'); + if ($wc.length) { + $wc.prop('disabled', false).css('opacity', '').removeAttr('aria-busy').show(); + $wc.data('mo-osp-waiting-for-response', false); + } + } else { + $btn.show(); + $btn.data('mo-osp-waiting-for-response', false); + } + } + + /** + * Initialize spam preventer (following resendcontrol pattern) + */ + function initializeSpamPreventer() { + if (isSpamPreventersInitialized) { + return; + } + + // Initialize browser ID + initializeBrowserID(); + + // Wait for any OTP button to appear, then bind events + waitForAnyElement(buttonSelectors, function(matchingSelector) { + bindSpamPreventionEvents(); + + // Check if we should auto-trigger Send OTP after puzzle verification + checkAndAutoTriggerSendOTP(); + }); + + isSpamPreventersInitialized = true; + } + + /** + * Show message after puzzle completion and prompt user to resubmit + */ + function checkAndAutoTriggerSendOTP() { + const puzzleCompleted = sessionStorage.getItem('mo_osp_puzzle_completed'); + + if (puzzleCompleted === 'true') { + + // Clear the flag from sessionStorage + sessionStorage.removeItem('mo_osp_puzzle_completed'); + } + } + + /** + * Initialize browser ID for tracking + */ + function initializeBrowserID() { + currentBrowserID = localStorage.getItem('mo_osp_browser_id'); + + if (!currentBrowserID) { + currentBrowserID = generateBrowserID(); + localStorage.setItem('mo_osp_browser_id', currentBrowserID); + } + + // Make globally available + window.mo_osp_browser_id = currentBrowserID; + } + + /** + * Generate unique browser ID + */ + function generateBrowserID() { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < 8; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + } + + /** + * Wait for any element to appear (resendcontrol pattern) + */ + function waitForAnyElement(selectors, callback) { + const interval = setInterval(() => { + const matchingSelector = selectors.find(selector => $mo(selector).length > 0); + if (matchingSelector) { + clearInterval(interval); + callback(matchingSelector); + } + }, 100); + + setTimeout(() => { + clearInterval(interval); + }, 3000); + } + + /** + * Bind spam prevention events (following resendcontrol pattern) + */ + function bindSpamPreventionEvents() { + const messageSelector = 'div[id*="mo_message"]'; + + buttonSelectors.forEach(function(buttonSelector) { + $mo(buttonSelector).each(function() { + const $mobutton = $mo(this); + + // Prevent multiple bindings + if ($mobutton.data('spam-preventer-bound')) { + return; + } + $mobutton.data('spam-preventer-bound', true); + + $mobutton.on('click', function(e) { + + // CRITICAL: Skip spam prevention checks for external popup buttons + // External popup handles its own validation and error messages + // Check at click time since external popup may be dynamically loaded + const isExternalPopupButton = ($mobutton.attr('id') === 'send_otp' && + $mo('#mo_site_otp_form').length > 0) || + ($mo('#mo_site_otp_form').length > 0 && + $mo('.mo_customer_validation-modal').length > 0 && + ($mobutton.closest('#mo_site_otp_form').length > 0 || + $mobutton.closest('.mo_customer_validation-modal').length > 0)); + + if (isExternalPopupButton) { + // Don't prevent default or stop propagation - let external popup handle it + // Just return without doing anything + return; + } + + // Check if puzzle was just completed (skip puzzle check, allow OTP to proceed) + if (window.mo_osp_puzzle_just_completed || window.mo_osp_puzzle_verified) { + window.mo_osp_puzzle_just_completed = false; // Clear the flag + // Check cooldown even after puzzle completion + checkCooldownBeforeOTPSend($mobutton, messageSelector, e); + return; + } + + // IMPORTANT: Don't check puzzle requirement if we're currently verifying + // This prevents redundant AJAX calls during puzzle verification flow + if (window.MO_OSP_Puzzle && window.MO_OSP_Puzzle.isShowing) { + // Allow default behavior to continue + setupPostOTPHandling($mobutton, messageSelector); + return; + } + + // First check cooldown, then check puzzle requirement + checkCooldownBeforeOTPSend($mobutton, messageSelector, e).then(function(cooldownResult) { + if (cooldownResult.onCooldown) { + // Cooldown is active, show error message and prevent OTP send + e.preventDefault(); + e.stopImmediatePropagation(); + showCooldownError($mobutton, messageSelector, cooldownResult.remainingTime); + return false; + } else { + // No cooldown, check if puzzle is required + checkPuzzleRequirement().then(function(result) { + if (result.puzzleRequired) { + e.preventDefault(); + e.stopImmediatePropagation(); + showPuzzlePopup(); + return false; + } else { + setupPostOTPHandling($mobutton, messageSelector); + } + }).catch(function(error) { + setupPostOTPHandling($mobutton, messageSelector); + }); + } + }).catch(function(error) { + // On error, proceed with normal flow + checkPuzzleRequirement().then(function(result) { + if (result.puzzleRequired) { + e.preventDefault(); + e.stopImmediatePropagation(); + showPuzzlePopup(); + return false; + } else { + setupPostOTPHandling($mobutton, messageSelector); + } + }).catch(function(error2) { + setupPostOTPHandling($mobutton, messageSelector); + }); + }); + }); + }); + }); + } + + /** + * Check if cooldown is active before OTP send + */ + function checkCooldownBeforeOTPSend($mobutton, messageSelector, e) { + return new Promise((resolve, reject) => { + if (typeof mo_osp_ajax === 'undefined') { + resolve({ onCooldown: false }); + return; + } + + const email = getEmailFromForm(); + const phone = getPhoneFromForm(); + + $mo.ajax({ + url: mo_osp_ajax.ajax_url, + type: 'POST', + dataType: 'json', + data: { + action: 'mo_osp_check_blocked', + nonce: mo_osp_ajax.nonce, + mo_osp_browser_id: currentBrowserID, + email: email, + phone: phone + }, + success: function(response) { + // Handle WordPress JSON success wrapper + if (response && response.data) { + response = response.data; + } + + if (response && response.cooldown && response.remaining_time > 0) { + resolve({ + onCooldown: true, + remainingTime: parseInt(response.remaining_time) + }); + } else { + resolve({ onCooldown: false }); + } + }, + error: function(xhr, status, error) { + // If response is HTML (error page), treat as no cooldown to allow OTP send + if (xhr.responseText && xhr.responseText.trim().startsWith('<')) { + resolve({ onCooldown: false }); + return; + } + resolve({ onCooldown: false }); + } + }); + }); + } + + /** + * Show cooldown error message with timer + */ + function showCooldownError($mobutton, messageSelector, remainingTime) { + + // Find or create message element + let $momessageElem = $mo(messageSelector); + if ($momessageElem.length === 0) { + $momessageElem = $mo('div[id*="mo_message"], #mo_message, .mo_message').first(); + } + + // If still no message element, try to find the form and create one + if ($momessageElem.length === 0) { + const $form = $mobutton.closest('form'); + if ($form.length > 0) { + // Try to find existing message container or create one + $momessageElem = $form.find('[id*="mo_message"], .mo_message').first(); + if ($momessageElem.length === 0) { + // Create message element + $momessageElem = $mo('<div id="mo_message" style="display:block;"></div>'); + $mobutton.before($momessageElem); + } + } + } + + if ($momessageElem.length === 0) { + // Last resort: create a message element at the button's location + $momessageElem = $mo('<div id="mo_message" style="display:block;margin:10px 0;"></div>'); + $mobutton.before($momessageElem); + } + + + // Format the error message with timer using USER_IS_BLOCKED_AJAX format + // Message: "You have exceeded the limit to send OTP. Please wait for {minutes}:{seconds} minutes" + const minutes = Math.floor(remainingTime / 60); + const seconds = remainingTime % 60; + const formattedMinutes = String(minutes).padStart(2, '0'); + const formattedSeconds = String(seconds).padStart(2, '0'); + + // Use the USER_IS_BLOCKED_AJAX message format + const errorMessage = 'You have exceeded the limit to send OTP. Please wait for ' + + formattedMinutes + ':' + formattedSeconds + ' minutes'; + + + // Display the error message + $momessageElem.text(errorMessage); + mospApplyMoMessageErrorStyles($momessageElem); + if (!isWcCheckoutPopupMessageDisplay($momessageElem)) { + $momessageElem.css({ + 'padding': '10px', + 'border-radius': '4px', + 'margin': '10px 0' + }); + } + $momessageElem.show(); + + // Start the timer (use isBlocked=true for error messages) + startBlockTimer(remainingTime, $mobutton, $momessageElem, errorMessage); + } + + /** + * Show puzzle popup for AJAX forms when puzzle_required response is received + */ + function showPuzzleForAjaxForm(response) { + + // Check if puzzle popup HTML exists (should be added by mosp_add_puzzle_popup_to_frontend) + if ($mo('#mo-osp-puzzle-overlay').length === 0) { + console.error('[OSP] Puzzle overlay not found! Make sure puzzle popup HTML is added to frontend.'); + // Show error message to user + const messageElement = findMessageElement(); + if (messageElement && messageElement.length > 0) { + const $msgEl = $mo(messageElement); + $msgEl.text('Puzzle verification required but puzzle system is not loaded. Please refresh the page.'); + mospApplyMoMessageErrorStyles($msgEl); + $msgEl.show(); + } + return; + } + + // CRITICAL: Ensure puzzle overlay has higher z-index than WooCommerce checkout popup + // WooCommerce checkout popup uses z-index: 100000, so puzzle needs to be higher + var $puzzleOverlay = $mo('#mo-osp-puzzle-overlay'); + $puzzleOverlay.css('z-index', '100001'); + + // Show the puzzle popup + $mo('#mo-osp-puzzle-popup-outer-div').show().css('z-index', '100002'); + $puzzleOverlay.removeClass('mo-osp-hidden'); + + // Set up callback for when puzzle is completed + window.MO_OSP_Puzzle_onAjaxSuccess = function(verificationData) { + + // PRIORITY 1: Try to resubmit stored AJAX request if available + if (window.mo_osp_pending_ajax_request) { + const originalRequest = window.mo_osp_pending_ajax_request; + + // Add puzzle verification data to the request + let requestData = originalRequest.data; + + // Handle both string and object data formats + if (typeof requestData === 'string') { + // Parse query string and add puzzle data + const params = new URLSearchParams(requestData); + params.set('puzzle_verified', 'true'); + if (verificationData && verificationData.puzzle_nonce) { + params.set('mo_osp_puzzle_nonce', verificationData.puzzle_nonce); + } + if (verificationData && verificationData.verification_token) { + params.set('verification_token', verificationData.verification_token); + } + requestData = params.toString(); + } else if (typeof requestData === 'object') { + // Add puzzle data to object + requestData.puzzle_verified = 'true'; + if (verificationData && verificationData.puzzle_nonce) { + requestData.mo_osp_puzzle_nonce = verificationData.puzzle_nonce; + } + if (verificationData && verificationData.verification_token) { + requestData.verification_token = verificationData.verification_token; + } + } + + + // Resubmit the original AJAX request with puzzle verification data + $mo.ajax({ + url: originalRequest.url, + type: originalRequest.type, + data: requestData, + dataType: originalRequest.dataType, + crossDomain: originalRequest.crossDomain, + success: function(response) { + // Call original success callback if it exists + if (originalRequest.originalSuccess) { + originalRequest.originalSuccess.call(this, response); + } + }, + error: function(jqXHR, textStatus, errorThrown) { + console.error('[OSP] Resubmitted AJAX request failed:', textStatus, errorThrown); + // Call original error callback if it exists + if (originalRequest.originalError) { + originalRequest.originalError.call(this, jqXHR, textStatus, errorThrown); + } + } + }); + + // Clear stored request + delete window.mo_osp_pending_ajax_request; + return; + } + + // PRIORITY 2: Fallback to button click if no stored request + const $button = $mo(buttonSelectors.join(',')).filter(':visible').first(); + if ($button.length > 0) { + // Trigger the button click again to resubmit OTP request + // The puzzle_verified flag will be added by puzzle-system.js + $button.trigger('click'); + } else { + console.error('[OSP] Could not find OTP button to resubmit request'); + console.error('[OSP] Available buttons:', $mo(buttonSelectors.join(',')).length); + console.error('[OSP] Button selectors:', buttonSelectors); + + // Last resort: Try to find any form and submit it + const $forms = $mo('form').not('#mo_validate_form').not('#validation_goBack_form').not('#verification_resend_otp_form'); + if ($forms.length > 0) { + const $form = $forms.first(); + + // Add puzzle verification data + if (!$form.find('input[name="puzzle_verified"]').length) { + $form.append('<input type="hidden" name="puzzle_verified" value="true">'); + } + if (verificationData && verificationData.puzzle_nonce && !$form.find('input[name="mo_osp_puzzle_nonce"]').length) { + $form.append('<input type="hidden" name="mo_osp_puzzle_nonce" value="' + verificationData.puzzle_nonce + '">'); + } + if (verificationData && verificationData.verification_token && !$form.find('input[name="verification_token"]').length) { + $form.append('<input type="hidden" name="verification_token" value="' + verificationData.verification_token + '">'); + } + + $form.submit(); + } else { + console.error('[OSP] No form found either. User may need to manually resubmit.'); + } + } + }; + + // Initialize and show puzzle if system is available + if (typeof window.MO_OSP_Puzzle !== 'undefined') { + if (typeof window.MO_OSP_Puzzle.init === 'function' && !window.MO_OSP_Puzzle.initialized) { + window.MO_OSP_Puzzle.init(); + window.MO_OSP_Puzzle.initialized = true; + } + window.MO_OSP_Puzzle.showPuzzle({}); + } else { + console.error('[OSP] MO_OSP_Puzzle not available yet, waiting...'); + // Wait for puzzle system to load + setTimeout(function() { + if (typeof window.MO_OSP_Puzzle !== 'undefined') { + if (typeof window.MO_OSP_Puzzle.init === 'function' && !window.MO_OSP_Puzzle.initialized) { + window.MO_OSP_Puzzle.init(); + window.MO_OSP_Puzzle.initialized = true; + } + window.MO_OSP_Puzzle.showPuzzle({}); + } else { + console.error('[OSP] MO_OSP_Puzzle still not available after wait'); + const messageElement = findMessageElement(); + if (messageElement && messageElement.length > 0) { + const $msgEl = $mo(messageElement); + $msgEl.text('Puzzle verification required but puzzle system failed to load. Please refresh the page.'); + mospApplyMoMessageErrorStyles($msgEl); + $msgEl.show(); + } + } + }, 500); + } + } + + /** + * Check if puzzle is required before OTP send + */ + function checkPuzzleRequirement() { + return new Promise((resolve, reject) => { + if (typeof mo_osp_ajax === 'undefined') { + resolve({ puzzleRequired: false }); + return; + } + + $mo.ajax({ + url: mo_osp_ajax.ajax_url, + type: 'POST', + data: { + action: 'mo_osp_check_puzzle_requirement', + nonce: mo_osp_ajax.nonce, + mo_osp_browser_id: currentBrowserID, + email: getEmailFromForm(), + phone: getPhoneFromForm() + }, + success: function(response) { + if (response.success && response.data) { + const puzzleRequired = response.data.puzzle_required === true; + resolve({ puzzleRequired: puzzleRequired }); + } else { + resolve({ puzzleRequired: false }); + } + }, + error: function() { + reject(new Error('Failed to check puzzle requirement')); + } + }); + }); + } + + /** + * Setup post-OTP handling - intercept AJAX responses and add timers + */ + function setupPostOTPHandling($mobutton, messageSelector) { + mospPrepareOtpButtonForRequest($mobutton); + } + + /** + * Intercept AJAX responses to add timers to messages + * This is called globally for all AJAX responses + */ + function interceptAjaxResponse(response, messageElement) { + + if (!response) { + return; + } + + // PRIORITY 0: Handle puzzle_required response for AJAX forms + if (response.result === 'puzzle_required' || response.puzzle_required === true || response.authType === 'PUZZLE_REQUIRED') { + showPuzzleForAjaxForm(response); + return; + } + + if (!response.message) { + return; + } + + const messageText = response.message; + const isSuccess = response.result === 'success' || response.result === 'SUCCESS'; + const isError = response.result === 'error' || response.result === 'ERROR'; + + // Find message element if not provided + if (!messageElement) { + messageElement = findMessageElement(); + } + + if (!messageElement || messageElement.length === 0) { + // Try again after a short delay + setTimeout(function() { + interceptAjaxResponse(response, null); + }, 200); + return; + } + + + // PRIORITY 1: Handle error/blocked responses with timer (cooldown/block) + // This should override any existing success messages + // Check for error response OR error message in text + const isBlockedError = (isError && response.blocked === true && response.remaining_time > 0) || + (messageText.includes('exceeded') && messageText.includes('limit') && messageText.match(/\d+:\d+/)); + + if (isBlockedError) { + const $messageElement = $mo(messageElement); + + // Extract remaining time from response or message text + let remainingTime = 0; + if (response.remaining_time && response.remaining_time > 0) { + remainingTime = response.remaining_time; + } else { + // Try to extract from message text + remainingTime = extractTimerFromMessage(messageText); + } + + if (remainingTime <= 0) { + return; + } + + // Stop any existing timers for this element + if ($messageElement.data('mo-osp-timer-active')) { + // Clear all active timers + activeTimers.forEach(function(timer) { + clearInterval(timer); + }); + activeTimers = []; + } + + // Clear any existing timer flags to allow replacement + $messageElement.data('mo-osp-timer-added', false); + $messageElement.data('mo-osp-timer-active', false); + + // Format the error message with timer + const minutes = Math.floor(remainingTime / 60); + const seconds = remainingTime % 60; + const formattedMinutes = String(minutes).padStart(2, '0'); + const formattedSeconds = String(seconds).padStart(2, '0'); + + // Use the message from response, or format it if it has placeholders + let errorMessage = messageText; + if (errorMessage.includes('{minutes}') || errorMessage.includes('{seconds}')) { + errorMessage = errorMessage.replace('{minutes}', formattedMinutes).replace('{seconds}', formattedSeconds); + } else if (!errorMessage.includes(formattedMinutes + ':' + formattedSeconds)) { + // If message doesn't have timer format, format it + if (errorMessage.includes('exceeded') && errorMessage.includes('limit')) { + // Extract the base message (before the timer) + const baseMessage = errorMessage.replace(/\d+:\d+\s*(?:minute|min)s?/i, '').trim(); + if (baseMessage.endsWith('Please wait for')) { + errorMessage = baseMessage.substring(0, baseMessage.lastIndexOf('Please wait for')).trim() + ' Please wait for ' + formattedMinutes + ':' + formattedSeconds + ' minutes'; + } else { + errorMessage = errorMessage.replace(/\d+:\d+\s*(?:minute|min)s?/i, formattedMinutes + ':' + formattedSeconds + ' minutes'); + } + } else { + errorMessage = messageText; + } + } + + // Replace the message content with error message (overwrite any success message) + $messageElement.text(errorMessage); + mospApplyMoMessageErrorStyles($messageElement); + $messageElement.show(); + + // Set a flag to prevent success message from overwriting this error + $messageElement.data('mo-osp-error-message', true); + + // Store error response for potential restoration if overwritten + window.mo_osp_last_error_response = { + blocked: true, + message: errorMessage, + remaining_time: remainingTime, + result: 'error' + }; + + // Start the timer (this will check for active timer, but we've cleared it) + const $button = findButtonForMessage(messageElement); + startBlockTimer(remainingTime, $button, messageElement, errorMessage); + return; + } + + // PRIORITY 2: Check if it's a blocked/error message with timer in text + if ((isError || messageText.includes('exceeded')) && messageText.includes('limit')) { + const totalSeconds = extractTimerFromMessage(messageText); + if (totalSeconds > 0) { + const $messageElement = $mo(messageElement); + // Clear any existing timer flags + $messageElement.data('mo-osp-timer-added', false); + $messageElement.data('mo-osp-timer-active', false); + // Update message styling for error + mospApplyMoMessageErrorStyles($messageElement); + const $button = findButtonForMessage(messageElement); + startBlockTimer(totalSeconds, $button, messageElement, messageText); + return; + } + } + + // Check if it's a success message (OTP actually sent — not mismatch/validation copy containing "OTP"/"sent") + // But don't process if we have an active error message + const $messageElement = $mo(messageElement); + if (isSuccess && mospMessageMatchesOtpSentResendTimerAllowlist(messageText)) { + // Check if we have an active error message - if so, don't overwrite it + if ($messageElement.data('mo-osp-error-message')) { + return; + } + + if (isWcCheckoutPopupMessageDisplay($messageElement)) { + neutralizeWcCheckoutPopupMessageStyle($messageElement); + } + + // CRITICAL: Skip cooldown check for external popup responses + // External popup handles its own success/error messages and shouldn't be overwritten + const isExternalPopup = ($messageElement.attr('id') === 'mo_message' && + $mo('#mo_site_otp_form').length > 0) || + ($mo('#mo_site_otp_form').length > 0 && + $mo('.mo_customer_validation-modal').length > 0); + + if (isExternalPopup) { + return; // Don't process external popup success messages - let external popup handle them + } + + // Get actual remaining cooldown time from backend + const email = getEmailFromForm(); + const phone = getPhoneFromForm(); + + if (typeof mo_osp_ajax !== 'undefined') { + $mo.ajax({ + url: mo_osp_ajax.ajax_url, + type: 'POST', + dataType: 'json', + data: { + action: 'mo_osp_check_blocked', + nonce: mo_osp_ajax.nonce, + mo_osp_browser_id: currentBrowserID, + email: email, + phone: phone + }, + success: function(cooldownResponse) { + // Handle WordPress JSON success wrapper + if (cooldownResponse && cooldownResponse.data) { + cooldownResponse = cooldownResponse.data; + } + + let cooldownTime = 0; + + // Check if user is on cooldown + if (cooldownResponse && cooldownResponse.cooldown && cooldownResponse.remaining_time > 0) { + cooldownTime = parseInt(cooldownResponse.remaining_time); + } else { + // If not on cooldown yet, use default cooldown time + cooldownTime = (typeof mo_osp_ajax !== 'undefined' && mo_osp_ajax.timer_time) + ? parseInt(mo_osp_ajax.timer_time) : 60; + } + + if (cooldownTime > 0) { + const $button = findButtonForMessage(messageElement); + startCooldownTimer(cooldownTime, $button, messageElement, messageText); + } else { + // No cooldown (likely whitelisted IP), show success message without timer + const $button = findButtonForMessage(messageElement); + if ($button && $button.length > 0) { + $button.show(); + } + // Update message to show success without timer text + if (messageElement && window.verifyOTPmessage) { + const $message = $mo(messageElement); + $message.text(window.verifyOTPmessage); + } + } + }, + error: function(xhr, status, error) { + // If response is HTML (error page), treat as no cooldown to allow OTP send + if (xhr.responseText && xhr.responseText.trim().startsWith('<')) { + // HTML response - likely an error page, skip timer + const $button = findButtonForMessage(messageElement); + if ($button && $button.length > 0) { + $button.show(); + } + return; + } + // Fallback to default cooldown time on error + const defaultTime = (typeof mo_osp_ajax !== 'undefined' && mo_osp_ajax.timer_time) + ? parseInt(mo_osp_ajax.timer_time) : 60; + const $button = findButtonForMessage(messageElement); + startCooldownTimer(defaultTime, $button, messageElement, messageText); + } + }); + } else { + // Fallback if mo_osp_ajax is not available + const defaultTime = 60; + const $button = findButtonForMessage(messageElement); + startCooldownTimer(defaultTime, $button, messageElement, messageText); + } + } + } + + /** + * Find message element using various selectors + */ + function findMessageElement() { + // Prefer WooCommerce checkout popup message so cooldown/timer targets the same node the user sees. + const $wcPopupMsg = $mo('#mo_message_wc_pop_up'); + if ($wcPopupMsg.length && $wcPopupMsg.is(':visible')) { + return $wcPopupMsg.first(); + } + + const selectors = [ + 'div[id*="mo_message"]', + '#mo_message', + '.mo_message', + '[id*="mo_message"]', + '[class*="mo_message"]' + ]; + + for (let i = 0; i < selectors.length; i++) { + const $elem = $mo(selectors[i]); + if ($elem.length > 0 && $elem.is(':visible')) { + return $elem.first(); + } + } + + return null; + } + + /** + * Find button associated with message element + */ + function findButtonForMessage($messageElement) { + if (!$messageElement || $messageElement.length === 0) { + return $mo(); + } + + // WC block checkout: message is #mo_message_wc_pop_up; do not use the first generic + // "Send OTP" control elsewhere in the checkout form (wrong target for hide/show/timer). + if (isWcCheckoutPopupMessageDisplay($messageElement)) { + const $wcBtn = $mo('button#miniorange_wc_popup_send_otp_token').first(); + if ($wcBtn.length > 0) { + return $wcBtn; + } + } + + // Try to find button near the message element + const $form = $messageElement.closest('form'); + if ($form.length > 0) { + for (let i = 0; i < buttonSelectors.length; i++) { + const $button = $form.find(buttonSelectors[i]); + if ($button.length > 0) { + return $button.first(); + } + } + } + + // Fallback: find any button with waiting flag + return $mo(buttonSelectors.join(', ')).filter(function() { + return $mo(this).data('mo-osp-waiting-for-response') === true; + }).first(); + } + + /** + * Extract timer from message text + */ + function extractTimerFromMessage(messageText) { + let totalSeconds = 0; + + // Pattern 1: MM:SS format (handle large numbers like 1430:57) + const timerMatch = messageText.match(/(\d{1,4}):(\d{2})/); + if (timerMatch) { + const minutes = parseInt(timerMatch[1]); + const seconds = parseInt(timerMatch[2]); + totalSeconds = (minutes * 60) + seconds; + return totalSeconds; + } + + // Pattern 2: "X minutes" format + const altTimerMatch = messageText.match(/(\d+)\s*minutes?/i); + if (altTimerMatch) { + totalSeconds = parseInt(altTimerMatch[1]) * 60; + return totalSeconds; + } + + // Pattern 3: "X seconds" format + const secTimerMatch = messageText.match(/(\d+)\s*seconds?/i); + if (secTimerMatch) { + totalSeconds = parseInt(secTimerMatch[1]); + return totalSeconds; + } + + return 0; + } + + /** + * Start cooldown timer (resendcontrol pattern) + */ + function startCooldownTimer(timeLeft, $mobutton, $momessageElem, message) { + + if ($momessageElem.length > 0) { + $momessageElem.show(); + + if (isWcCheckoutPopupMessageDisplay($momessageElem)) { + neutralizeWcCheckoutPopupMessageStyle($momessageElem); + } else { + // CRITICAL: Preserve success message styling (green background, dark text) + const bgColor = $momessageElem.css('background-color'); + const isSuccessMessage = bgColor && ( + bgColor === 'rgb(142, 237, 142)' || + bgColor === '#8eed8e' || + bgColor.indexOf('142, 237, 142') !== -1 || + $momessageElem.css('background-color').indexOf('8eed8e') !== -1 + ); + + if (isSuccessMessage) { + $momessageElem.css({ + 'color': '#464646', + 'background-color': '#8eed8e', + }); + } + } + } + + startTimer(timeLeft, $momessageElem[0], $mobutton, message, false); + } + + /** + * Start block timer (resendcontrol pattern) + */ + function startBlockTimer(timeLeft, $mobutton, $momessageElem, message) { + + if ($momessageElem.length > 0) { + $momessageElem.show(); + } + + startTimer(timeLeft, $momessageElem[0], $mobutton, message, true); + } + + function isWcCheckoutPopupMessageDisplay($display) { + const $d = $mo($display); + return $d.length && $d.attr('id') === 'mo_message_wc_pop_up'; + } + + /** + * WC block/classic checkout popup: success text should have no inline error/success colors. + */ + function neutralizeWcCheckoutPopupMessageStyle($display) { + const $d = $mo($display); + if (!isWcCheckoutPopupMessageDisplay($d)) { + return; + } + $d.removeAttr('style'); + $d.removeData('mo-osp-error-message'); + } + + /** + * Error styling: WC checkout popup has no pink background; other mo_message containers keep the alert bar. + */ + function mospApplyMoMessageErrorStyles($el) { + const $e = $mo($el); + if (!$e.length) { + return; + } + if (isWcCheckoutPopupMessageDisplay($e)) { + $e.css({ + 'background-color': 'transparent', + 'background': 'none', + 'color': '#ff5b5b' + }); + return; + } + $e.css({ + 'background-color': '#ffefef', + 'color': '#ff5b5b' + }); + } + + /** + * Check if message container is plugin-owned. + */ + function isPluginMessageContainer($display) { + if (!$display || $display.length === 0) { + return false; + } + if ($display.is('#mo_message, #mo_message_wc_pop_up, .mo_message')) { + return true; + } + const id = ($display.attr('id') || '').toLowerCase(); + if (id.indexOf('mo_message') !== -1) { + return true; + } + const className = ($display.attr('class') || '').toLowerCase(); + return className.indexOf('mo_message') !== -1; + } + + /** + * Generic timer function (optimized) + */ + function startTimer(duration, display, button, displayMessage, isBlocked) { + if (!display) { + return; + } + + // If duration is 0 or less, don't start timer (for whitelisted IPs or when no cooldown). + if (duration <= 0) { + const $display = $mo(display); + // Just show the message without timer + if (displayMessage && window.verifyOTPmessage) { + $display.text(window.verifyOTPmessage); + } else if (displayMessage) { + $display.text(displayMessage); + } + neutralizeWcCheckoutPopupMessageStyle($display); + return; + } + + const $display = $mo(display); + const shouldAppendTimer = isPluginMessageContainer($display); + + // Check if timer is already active - prevent duplicate timers + if ($display.data('mo-osp-timer-active')) { + return; + } + + // Mark as having timer to prevent duplicates + $display.data('mo-osp-timer-active', true); + + let timer = duration; + + // Update immediately + const minutes = String(Math.floor(timer / 60)).padStart(2, '0'); + const seconds = String(timer % 60).padStart(2, '0'); + const formattedMessage = formatTimerMessage(displayMessage, minutes, seconds, timer, isBlocked, shouldAppendTimer); + $display.text(formattedMessage); + + // CRITICAL: Set success styling AFTER text update to override any inline styles + if (!isBlocked) { + if (isWcCheckoutPopupMessageDisplay($display)) { + neutralizeWcCheckoutPopupMessageStyle($display); + } else { + const bgColor = $display.css('background-color'); + const isSuccessMessage = bgColor && ( + bgColor === 'rgb(142, 237, 142)' || + bgColor === '#8eed8e' || + bgColor.indexOf('142, 237, 142') !== -1 + ); + + if (isSuccessMessage) { + // Override inline styles to ensure correct success styling + $display.css({ + 'color': '#464646', // Dark green text for better readability on green background + 'background-color': '#8eed8e', // Green background + }); + } + } + } + + $display.show(); + + const timerFunction = setInterval(() => { + // Check if error message flag is set - if so, this timer should stop (error message takes priority) + if ($display.data('mo-osp-error-message') && !isBlocked) { + clearInterval(timerFunction); + const index = activeTimers.indexOf(timerFunction); + if (index > -1) { + activeTimers.splice(index, 1); + } + return; + } + + // If verification success/failure text is now shown, stop resend timer so + // it cannot overwrite this message with old OTP-sent cooldown content. + const liveText = mospNormalizeMessageText($display.text() || ''); + if (!isBlocked && liveText) { + const liveBaseText = liveText.replace(/\s*you can send the next otp after\s+\d{1,2}:\d{2}\.?/gi, '').trim(); + const hasOutcomeMessage = mospIsOtpVerificationOutcomeMessage(liveBaseText) || mospIsNegativeOtpFeedbackMessage(liveBaseText); + const isOtpSentCopy = mospMessageMatchesOtpSentResendTimerAllowlist(liveBaseText); + if (hasOutcomeMessage && !isOtpSentCopy) { + clearInterval(timerFunction); + $display.data('mo-osp-timer-active', false); + $display.data('mo-osp-timer-added', false); + const activeIndex = activeTimers.indexOf(timerFunction); + if (activeIndex > -1) { + activeTimers.splice(activeIndex, 1); + } + return; + } + } + + timer--; + + if (timer < 0) { + clearInterval(timerFunction); + + // Remove timer flag + $display.data('mo-osp-timer-active', false); + $display.data('mo-osp-timer-added', false); + $display.data('mo-osp-error-message', false); + + neutralizeWcCheckoutPopupMessageStyle($display); + + // WC checkout popup + success resend cooldown: keep OTP-sent text visible (strip countdown only). + const domEl = display && display.nodeType === 1 ? display : null; + const isWcPopupTarget = !isBlocked && ( + isWcCheckoutPopupMessageDisplay($display) || + (domEl && domEl.id === 'mo_message_wc_pop_up') + ); + if (isWcPopupTarget) { + const stripResendLine = function (t) { + if (!t || typeof t !== 'string') { + return ''; + } + return t.replace(/\s*You can send the next OTP after\s+\d{1,2}:\d{2}\.?/gi, '').trim(); + }; + let baseMsg = stripResendLine(typeof displayMessage === 'string' ? mospStripHtml(displayMessage) : ''); + if (!baseMsg && window.verifyOTPmessage) { + baseMsg = stripResendLine(mospStripHtml(String(window.verifyOTPmessage))); + } + if (!baseMsg) { + baseMsg = stripResendLine($display.text() || ''); + } + $display.text(baseMsg); + $display.show(); + } else { + $display.hide(); + } + if (button && button.length > 0) { + mospRestoreOtpButtonAfterRequest($mo(button)); + } + + // Remove from active timers + const index = activeTimers.indexOf(timerFunction); + if (index > -1) { + activeTimers.splice(index, 1); + } + return; + } + + const minutes = String(Math.floor(timer / 60)).padStart(2, '0'); + const seconds = String(timer % 60).padStart(2, '0'); + const formattedMessage = formatTimerMessage(displayMessage, minutes, seconds, timer, isBlocked, shouldAppendTimer); + + // Always update the message text + // For error messages (isBlocked=true), we need to update the countdown + // For success messages, we also need to update the countdown + $display.text(formattedMessage); + + // CRITICAL: Set success styling AFTER text update to override any inline styles + if (!isBlocked) { + if (isWcCheckoutPopupMessageDisplay($display)) { + neutralizeWcCheckoutPopupMessageStyle($display); + } else { + const bgColor = $display.css('background-color'); + const isSuccessMessage = bgColor && ( + bgColor === 'rgb(142, 237, 142)' || + bgColor === '#8eed8e' || + bgColor.indexOf('142, 237, 142') !== -1 + ); + + if (isSuccessMessage) { + // Override inline styles to ensure correct success styling + $display.css({ + 'color': '#464646', // Dark green text for readability + 'background-color': '#8eed8e', // Green background + }); + } + } + } + }, 1000); + + activeTimers.push(timerFunction); + } + + /** + * Plain text from HTML (e.g. verifyOTPmessage may contain markup). + */ + function mospStripHtml(str) { + if (!str || typeof str !== 'string') { + return ''; + } + const tmp = document.createElement('div'); + tmp.innerHTML = str; + return (tmp.textContent || tmp.innerText || '').trim(); + } + + /** + * Normalize text for message classification (NBSP, trim). + */ + function mospNormalizeMessageText(text) { + if (!text || typeof text !== 'string') { + return ''; + } + return text.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim(); + } + + /** + * Validation / mismatch / failure copy — never append resend countdown to these. + */ + function mospIsNegativeOtpFeedbackMessage(text) { + if (!text || typeof text !== 'string') { + return false; + } + const t = mospNormalizeMessageText(text).toLowerCase(); + // Mismatch / comparison errors often contain "OTP" and "sent" but are not send-success (e.g. WC phone mismatch). + if (/\b(do\s+not|does\s+not|did\s+not|don'?t)\s+match\b/.test(t)) { + return true; + } + if (/\bnot\s+match\b/.test(t) && /\b(?:otp|phone|email|number|verification|code|contact|submission)\b/.test(t)) { + return true; + } + if (/\botp\b.*\bsent\b.*\bnot\s+match\b/.test(t) || /\bsent\b.*\botp\b.*\bnot\s+match\b/.test(t)) { + return true; + } + // Normal send-success copy includes "Please enter the OTP below" — not a validation failure. + if (/\b(?:otp|passcode|verification\s+code|sms\s+code|code)\s+has\s+been\s+sent\b/.test(t)) { + return false; + } + if (/\bhas\s+been\s+sent\s+to\b/.test(t)) { + return false; + } + return /\b(mismatch|invalid|incorrect|failed|failure|unsuccessful|wrong\s+(?:otp|code|number)|expired|verification\s+failed|not\s+verified|unable\s+to|could\s+not|must\s+enter|please\s+enter|is\s+required|are\s+required|\berror\b|exceeded\s+the\s+limit|try\s+again)\b/i.test(t); + } + + /** + * OTP verification outcome copy (success/failure). If this appears while resend timer + * is running, timer must stop and preserve this message. + */ + function mospIsOtpVerificationOutcomeMessage(text) { + if (!text || typeof text !== 'string') { + return false; + } + const t = mospNormalizeMessageText(text).toLowerCase(); + if (!t) { + return false; + } + if (t === 'success' || t === 'otp verified' || t === 'otp verification successful') { + return true; + } + return /\b(otp|one time passcode|verification\s+code|code)\b.*\b(verified|validated|successful|successfully)\b/.test(t) || + /\b(verified|validated|successful|successfully)\b.*\b(otp|one time passcode|verification\s+code|code)\b/.test(t); + } + + /** + * Messages that may receive the client line: "You can send the next OTP after MM:SS." + * Mirrors default English strings from MoMessages (OTP_SENT_PHONE, OTP_SENT_EMAIL, OTP_SENT, SMS_SENT_SUCCESS). + * Customized admin messages that change wording will not match until they keep the same opening/closing phrases. + * + * Not included: LIMIT_OTP_SENT / USER_IS_BLOCKED_* (server already supplies cooldown text); + * CHOOSE_METHOD / mismatch / error strings. + */ + function mospMessageMatchesOtpSentResendTimerAllowlist(text) { + if (!text || typeof text !== 'string') { + return false; + } + if (mospIsNegativeOtpFeedbackMessage(text)) { + return false; + } + const plain = mospStripHtml(String(text)); + let base = mospNormalizeMessageText(plain).replace(/\s+/g, ' ').trim().toLowerCase(); + base = base.replace(/\s*you can send the next otp after\s+\d{1,2}:\d{2}\.?/gi, '').trim(); + + if (/click\s+here\s+to\s+send\s+otp|send\s+otp\s+to\s+continue/i.test(base)) { + return false; + } + + // MoMessages::OTP_SENT_PHONE — "A OTP (One Time Passcode) has been sent to … Please enter the OTP in the field below to verify your phone." + if (base.indexOf('a otp (one time passcode) has been sent to ') === 0 && + base.indexOf('please enter the otp in the field below to verify your phone') !== -1) { + return true; + } + + // MoMessages::OTP_SENT_EMAIL — "A One Time Passcode has been sent to … Please enter the OTP below to verify your Email Address" + if (base.indexOf('a one time passcode has been sent to ') === 0 && + base.indexOf('please enter the otp below to verify your email address') !== -1) { + return true; + } + + // MoMessages::OTP_SENT — "A passcode has been sent to {{method}}. Please enter the otp below to verify your account." + if (base.indexOf('a passcode has been sent to ') === 0 && + base.indexOf('please enter the otp below to verify your account') !== -1) { + return true; + } + + // MoMessages::SMS_SENT_SUCCESS + if (base === 'sms was sent successfully.' || base === 'sms was sent successfully') { + return true; + } + + return false; + } + + /** + * Format timer message with countdown (consolidated logic) + */ + function formatTimerMessage(displayMessage, minutes, seconds, totalSeconds, isBlocked, shouldAppendTimer) { + const strippedDisplay = typeof displayMessage === 'string' + ? displayMessage.replace(/\s*You can send the next OTP after\s+\d{1,2}:\d{2}\.?/gi, '').trim() + : displayMessage; + let serverMessage = strippedDisplay || (isBlocked ? 'You are temporarily blocked.' : 'Please wait before requesting another OTP.'); + + // Never attach or keep the resend line on validation / mismatch messages. + if (!isBlocked && mospIsNegativeOtpFeedbackMessage(serverMessage)) { + return serverMessage; + } + + // Check if message has placeholder patterns + if (serverMessage.includes('{minutes}') && serverMessage.includes('{seconds}')) { + return serverMessage.replace('{minutes}', minutes).replace('{seconds}', seconds); + } + + // Check if message already contains timer information (avoid duplication) + if (serverMessage.match(/\d{1,4}:\d{2}\s*(minutes?|mins?)/i)) { + return serverMessage.replace(/\d{1,4}:\d{2}\s*(minutes?|mins?)/gi, `${minutes}:${seconds} minutes`); + } + + if (serverMessage.match(/\d+\s*(minutes?|mins?)/i)) { + const totalMinutes = Math.floor(totalSeconds / 60); + return serverMessage.replace(/\d+\s*(minutes?|mins?)/gi, `${totalMinutes} minutes`); + } + + if (serverMessage.match(/\d+\s*(seconds?|secs?)/i)) { + return serverMessage.replace(/\d+\s*(seconds?|secs?)/gi, `${totalSeconds} seconds`); + } + + // If totalSeconds is 0, don't add timer text (for whitelisted IPs or when no cooldown). + if (totalSeconds <= 0) { + if (!isBlocked && window.verifyOTPmessage) { + return window.verifyOTPmessage; + } + return serverMessage; + } + + // For cooldown messages, try to use window.verifyOTPmessage if available and no server message + if (!isBlocked && (!strippedDisplay || strippedDisplay.trim() === '') && window.verifyOTPmessage) { + if (!shouldAppendTimer) { + return window.verifyOTPmessage; + } + const verifyPlain = mospStripHtml(String(window.verifyOTPmessage)); + if (mospIsNegativeOtpFeedbackMessage(verifyPlain) || !mospMessageMatchesOtpSentResendTimerAllowlist(verifyPlain)) { + return window.verifyOTPmessage; + } + return window.verifyOTPmessage + ` You can send the next OTP after ${minutes}:${seconds}.`; + } + + // Fallback: add countdown to server message (only for allowlisted OTP-sent copy from MoMessages) + if (!isBlocked) { + if (!shouldAppendTimer || mospIsNegativeOtpFeedbackMessage(serverMessage) || !mospMessageMatchesOtpSentResendTimerAllowlist(serverMessage)) { + return serverMessage; + } + return `${serverMessage} You can send the next OTP after ${minutes}:${seconds}.`; + } + return `${serverMessage} (${minutes}:${seconds} remaining)`; + } + + /** + * Show puzzle popup + */ + function showPuzzlePopup() { + if (typeof MO_OSP_Puzzle !== 'undefined') { + // CRITICAL: Ensure puzzle overlay has higher z-index than WooCommerce checkout popup + // WooCommerce checkout popup uses z-index: 100000, so puzzle needs to be higher + var $puzzleOverlay = $mo('#mo-osp-puzzle-overlay'); + if ($puzzleOverlay.length > 0) { + $puzzleOverlay.css('z-index', '100001'); + } + var $puzzlePopup = $mo('#mo-osp-puzzle-popup-outer-div'); + if ($puzzlePopup.length > 0) { + $puzzlePopup.css('z-index', '100002'); + } + MO_OSP_Puzzle.showPuzzle({}); + } else { + alert('Security verification required. Please refresh the page.'); + } + } + + /** + * Clear all active timers + */ + function clearAllTimers() { + activeTimers.forEach(function(timer) { + clearInterval(timer); + }); + activeTimers = []; + } + + /** + * Get email from form fields (with phone fallback for consistency) + */ + function getEmailFromForm() { + let email = ''; + $mo('input[type="email"], input[name*="email"], input[id*="email"]').each(function() { + const $field = $mo(this); + const type = ($field.attr('type') || '').toLowerCase(); + if (type === 'button' || type === 'submit' || type === 'reset') { + return; + } + const value = $field.val(); + if (value && !/send\s+otp|verify\s+otp/i.test(value)) { + email = value; + return false; + } + }); + + // If no email found, use phone number as email for consistency + if (!email) { + const phone = getPhoneFromForm(); + if (phone) { + email = phone; + } + } + return email; + } + + /** + * Get phone from form fields + */ + function getPhoneFromForm() { + let phone = ''; + $mo('input[type="tel"], input[name*="phone"], input[id*="phone"], input[name*="mobile"]').each(function() { + const $field = $mo(this); + const type = ($field.attr('type') || '').toLowerCase(); + if (type === 'button' || type === 'submit' || type === 'reset') { + return; + } + const value = $field.val(); + if (!value || /send\s+otp|verify\s+otp/i.test(value)) { + return; + } + // Normalize to digits/+ and require a minimum length to avoid tokens like "6ff2c895dc". + const normalized = String(value).replace(/[^0-9+]/g, ''); + const digitCount = normalized.replace(/\D/g, '').length; + if (digitCount >= 6) { + phone = normalized; + return false; + } + }); + return phone; + } + + /** + * Handle puzzle success (called by puzzle system) + */ + window.MO_OSP_SpamPreventer_onPuzzleSuccess = function() { + clearAllTimers(); + + // Set flag to allow OTP to proceed after puzzle completion + window.mo_osp_skip_puzzle_check = true; + setTimeout(function() { + window.mo_osp_skip_puzzle_check = false; + }, 5000); + + // Clear any existing verifyOTPmessage + if (window.verifyOTPmessage) { + delete window.verifyOTPmessage; + } + + // Hide any existing messages (never hide WC checkout popup line — id contains substring "mo_message") + $mo('div[id*="mo_message"]').not('#mo_message_wc_pop_up').hide(); + + // Show all OTP buttons + buttonSelectors.forEach(function(selector) { + $mo(selector).each(function() { + mospRestoreOtpButtonAfterRequest($mo(this)); + }); + }); + }; + + /** + * Setup global AJAX response interceptor and message monitor + */ + function setupAjaxInterceptor() { + // Monitor for message elements that are added or updated + const messageObserver = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + // Check added nodes + mutation.addedNodes.forEach(function(node) { + if (node.nodeType === 1) { // Element node + const $node = $mo(node); + const $messageCandidate = $node.is('[id*="mo_message"], .mo_message, [class*="mo_message"]') + ? $node + : $node.find('[id*="mo_message"], .mo_message, [class*="mo_message"]').first(); + if ($messageCandidate.length > 0) { + checkAndAddTimerToMessage($messageCandidate); + } + } + }); + + // Check for text changes in existing message elements + if (mutation.type === 'childList' || mutation.type === 'characterData') { + const target = mutation.target; + if (target.nodeType === 1) { + const $target = $mo(target); + if ($target.is('[id*="mo_message"], .mo_message, [class*="mo_message"]') || + $target.find('[id*="mo_message"], .mo_message').length > 0) { + // Use immediate check (no setTimeout) to catch error messages before they're overwritten + const $msgElem = $target.is('[id*="mo_message"], .mo_message') ? $target : $target.find('[id*="mo_message"], .mo_message').first(); + if ($msgElem.length > 0) { + const currentText = $msgElem.text() || ''; + + // PRIORITY: Check if error message just appeared + // But only process if we haven't already processed this exact message + if (currentText.includes('exceeded') && currentText.includes('limit')) { + const lastProcessedError = $msgElem.data('mo-osp-last-processed-error'); + const timerActive = $msgElem.data('mo-osp-timer-active'); + if (lastProcessedError !== currentText || !timerActive) { + checkAndAddTimerToMessage($msgElem); + return; + } else { + // Already processed this error message and timer is active, skip to prevent loop + return; + } + } + + // Check if error message flag is set but message was overwritten with success + if ($msgElem.data('mo-osp-error-message')) { + // If error flag is set but message is success, restore error message + if (currentText.includes('sent') && currentText.includes('OTP') && !currentText.includes('exceeded')) { + // The error should have been set by interceptAjaxResponse, but if it was overwritten, + // we need to check if we have the error response stored + if (window.mo_osp_last_error_response && window.mo_osp_last_error_response.blocked) { + const errorResponse = window.mo_osp_last_error_response; + const minutes = Math.floor(errorResponse.remaining_time / 60); + const seconds = errorResponse.remaining_time % 60; + const formattedMinutes = String(minutes).padStart(2, '0'); + const formattedSeconds = String(seconds).padStart(2, '0'); + let errorMessage = errorResponse.message || 'You have exceeded the limit to send OTP. Please wait for ' + + formattedMinutes + ':' + formattedSeconds + ' minutes'; + if (errorMessage.includes('{minutes}') || errorMessage.includes('{seconds}')) { + errorMessage = errorMessage.replace('{minutes}', formattedMinutes).replace('{seconds}', formattedSeconds); + } + $msgElem.text(errorMessage); + mospApplyMoMessageErrorStyles($msgElem); + // Clear timer flags and restart timer + $msgElem.data('mo-osp-timer-active', false); + $msgElem.data('mo-osp-timer-added', false); + const $button = findButtonForMessage($msgElem); + startBlockTimer(errorResponse.remaining_time, $button, $msgElem, errorMessage); + return; + } + } + } + + // For other messages, use setTimeout to avoid too many checks + setTimeout(function() { + checkAndAddTimerToMessage($msgElem); + }, 100); + } + } + } + } + }); + }); + + // Start observing the document body for changes + if (document.body) { + messageObserver.observe(document.body, { + childList: true, + subtree: true, + characterData: true + }); + } + + // Also check existing messages periodically (fallback) + setInterval(function() { + const $messages = $mo('[id*="mo_message"], .mo_message'); + $messages.each(function() { + const $msg = $mo(this); + if ($msg.is(':visible') && !$msg.data('mo-osp-timer-added')) { + checkAndAddTimerToMessage($msg); + } + }); + }, 500); + + // Intercept jQuery AJAX responses and store request data for puzzle resubmission + const originalAjax = $mo.ajax; + $mo.ajax = function(options) { + const originalSuccess = options.success; + const originalError = options.error; + + // Check if this is an OTP-related request by examining URL or data + const isOtpRequest = (options.url && ( + options.url.indexOf('admin-ajax.php') !== -1 || + options.url.indexOf('otp') !== -1 || + options.url.indexOf('miniorange') !== -1 + )) || (options.data && ( + (typeof options.data === 'string' && (options.data.indexOf('otp') !== -1 || options.data.indexOf('miniorange') !== -1)) || + (typeof options.data === 'object' && (options.data.action && ( + options.data.action.indexOf('otp') !== -1 || + options.data.action.indexOf('miniorange') !== -1 || + options.data.action === 'mo_external_popup_option' + ))) + )); + + // Wrap success callback to check for puzzle_required + options.success = function(response, textStatus, jqXHR) { + // Check if this is an external popup request + const isExternalPopupRequest = options.data && ( + (typeof options.data === 'object' && options.data.action === 'mo_external_popup_option') || + (typeof options.data === 'string' && options.data.indexOf('mo_external_popup_option') !== -1) + ); + + // Check if puzzle is required - if so, store the request for resubmission + if (isOtpRequest && response && (response.result === 'puzzle_required' || response.puzzle_required === true || response.authType === 'PUZZLE_REQUIRED')) { + window.mo_osp_pending_ajax_request = { + url: options.url, + type: options.type || 'POST', + data: typeof options.data === 'string' ? options.data : (options.data ? JSON.parse(JSON.stringify(options.data)) : {}), + dataType: options.dataType || 'json', + crossDomain: options.crossDomain || false, + originalSuccess: originalSuccess, + originalError: originalError + }; + } + + // CRITICAL: Skip interceptAjaxResponse for external popup success responses + // External popup handles its own success/error messages and shouldn't be overwritten + if (isExternalPopupRequest && response && response.result === 'success') { + // Call original success callback without intercepting + if (originalSuccess) { + originalSuccess.apply(this, arguments); + } + return; + } + + // Call interceptAjaxResponse if response has message + if (response && (response.message || response.result)) { + interceptAjaxResponse(response, null); + } + + // Call original success callback + if (originalSuccess) { + originalSuccess.apply(this, arguments); + } + }; + + // Wrap error callback + options.error = function(jqXHR, textStatus, errorThrown) { + // Try to parse error response + try { + const response = jqXHR.responseJSON || JSON.parse(jqXHR.responseText); + + // Check if puzzle is required in error response + if (isOtpRequest && response && (response.result === 'puzzle_required' || response.puzzle_required === true || response.authType === 'PUZZLE_REQUIRED')) { + window.mo_osp_pending_ajax_request = { + url: options.url, + type: options.type || 'POST', + data: typeof options.data === 'string' ? options.data : (options.data ? JSON.parse(JSON.stringify(options.data)) : {}), + dataType: options.dataType || 'json', + crossDomain: options.crossDomain || false, + originalSuccess: originalSuccess, + originalError: originalError + }; + } + + if (response && (response.message || response.result)) { + interceptAjaxResponse(response, null); + } + } catch (e) { + // Ignore parse errors + } + + // Call original error callback + if (originalError) { + originalError.apply(this, arguments); + } + }; + + // Call original ajax + return originalAjax.apply(this, arguments); + }; + } + + /** + * Check message element and add timer if needed + */ + function checkAndAddTimerToMessage($messageElement) { + if (!$messageElement || $messageElement.length === 0) { + return; + } + + const messageSelector = '[id*="mo_message"], .mo_message, [class*="mo_message"]'; + // Only act on OTP message containers to avoid corrupting unrelated text + if (!$messageElement.is(messageSelector)) { + const $innerMessage = $messageElement.find(messageSelector).first(); + if ($innerMessage.length === 0) { + return; + } + $messageElement = $innerMessage; + } + const messageText = $messageElement.text() || ''; + + if (!messageText.trim()) { + return; + } + + // PRIORITY: Check if it's a blocked/error message with timer + // Error messages should always replace success messages, even if timer is already added + if (messageText.includes('exceeded') && messageText.includes('limit')) { + + const timerActive = $messageElement.data('mo-osp-timer-active'); + + // Check if we've already processed this exact error message to prevent infinite loops + const lastProcessedError = $messageElement.data('mo-osp-last-processed-error'); + if (lastProcessedError === messageText && timerActive) { + return; + } + + // CRITICAL: Set error flag FIRST, then stop timer + // This ensures that if the timer interval callback is already queued, it will see the flag and stop + $messageElement.data('mo-osp-error-message', true); + $messageElement.data('mo-osp-last-processed-error', messageText); + + // IMPORTANT: Set the error message text IMMEDIATELY + // This prevents the success timer (if still running) from overwriting it + // But only if the current text is different to avoid triggering unnecessary mutations + const currentText = $messageElement.text() || ''; + if (currentText !== messageText) { + $messageElement.text(messageText); + } + mospApplyMoMessageErrorStyles($messageElement); + $messageElement.show(); + + const totalSeconds = extractTimerFromMessage(messageText); + if (totalSeconds > 0) { + // Check if interceptAjaxResponse already handled this error message + // If the timer is active and the error flag is set, interceptAjaxResponse likely already started it + if (timerActive && $messageElement.data('mo-osp-error-message')) { + return; + } + + // CRITICAL: ALWAYS stop any active timer when error message is detected + // The success timer might be running and overwriting the error message + // We MUST stop it immediately, regardless of what the current text says + if (timerActive) { + // Clear all active timers (whether success or error) + activeTimers.forEach(function(timer) { + clearInterval(timer); + }); + activeTimers = []; + // Clear flags AFTER clearing timers to ensure clean state + $messageElement.data('mo-osp-timer-active', false); + $messageElement.data('mo-osp-timer-added', false); + } + + // Set timer-added flag (but NOT timer-active - let startTimer set that) + $messageElement.data('mo-osp-timer-added', true); + + // Store error response for potential restoration if overwritten + window.mo_osp_last_error_response = { + blocked: true, + message: messageText, + remaining_time: totalSeconds, + result: 'error' + }; + + const $button = findButtonForMessage($messageElement); + startBlockTimer(totalSeconds, $button, $messageElement, messageText); + return; + } + } + + // Success OTP-sent only (MutationObserver): same rules as interceptAjaxResponse — not generic "sent"/"OTP" + const looksLikeSendPrompt = /click\s+here\s+to\s+send\s+otp|send\s+otp/i.test(messageText); + if (mospMessageMatchesOtpSentResendTimerAllowlist(messageText) && !looksLikeSendPrompt && + !messageText.match(/\d{1,2}:\d{2}\s*(remaining|minutes?|mins?)/i)) { + + // Check if we have an active error message - if so, don't process success message + if ($messageElement.data('mo-osp-error-message')) { + return; + } + + if (isWcCheckoutPopupMessageDisplay($messageElement)) { + neutralizeWcCheckoutPopupMessageStyle($messageElement); + } + + // Skip if timer already added (only for success messages) + if ($messageElement.data('mo-osp-timer-added')) { + return; + } + $messageElement.data('mo-osp-timer-added', true); + + // Get actual remaining cooldown time from backend + const email = getEmailFromForm(); + const phone = getPhoneFromForm(); + + if (typeof mo_osp_ajax !== 'undefined') { + $mo.ajax({ + url: mo_osp_ajax.ajax_url, + type: 'POST', + dataType: 'json', + data: { + action: 'mo_osp_check_blocked', + nonce: mo_osp_ajax.nonce, + mo_osp_browser_id: currentBrowserID, + email: email, + phone: phone + }, + success: function(cooldownResponse) { + // Handle WordPress JSON success wrapper + if (cooldownResponse && cooldownResponse.data) { + cooldownResponse = cooldownResponse.data; + } + + let cooldownTime = 0; + if (cooldownResponse && cooldownResponse.cooldown && cooldownResponse.remaining_time > 0) { + cooldownTime = parseInt(cooldownResponse.remaining_time); + } else { + cooldownTime = (typeof mo_osp_ajax !== 'undefined' && mo_osp_ajax.timer_time) + ? parseInt(mo_osp_ajax.timer_time) : 60; + } + if (cooldownTime > 0) { + const $button = findButtonForMessage($messageElement); + startCooldownTimer(cooldownTime, $button, $messageElement, messageText); + } else { + // No cooldown, show button + const $button = findButtonForMessage($messageElement); + if ($button && $button.length > 0) { + mospRestoreOtpButtonAfterRequest($button); + } + $messageElement.data('mo-osp-timer-added', false); // Allow retry + } + }, + error: function(xhr, status, error) { + // If response is HTML (error page), treat as no cooldown to allow OTP send + if (xhr.responseText && xhr.responseText.trim().startsWith('<')) { + // HTML response - likely an error page, skip timer + const $button = findButtonForMessage($messageElement); + if ($button && $button.length > 0) { + mospRestoreOtpButtonAfterRequest($button); + } + $messageElement.data('mo-osp-timer-added', false); + return; + } + const defaultTime = (typeof mo_osp_ajax !== 'undefined' && mo_osp_ajax.timer_time) + ? parseInt(mo_osp_ajax.timer_time) : 60; + const $button = findButtonForMessage($messageElement); + startCooldownTimer(defaultTime, $button, $messageElement, messageText); + } + }); + } else { + // Fallback if mo_osp_ajax is not available + const defaultTime = 60; + const $button = findButtonForMessage($messageElement); + startCooldownTimer(defaultTime, $button, $messageElement, messageText); + } + } + } + + // Initialize when document is ready (resendcontrol pattern) + $mo(document).ready(function() { + setTimeout(function() { + initializeSpamPreventer(); + setupAjaxInterceptor(); + }, 100); + }); + +})(jQuery); @@ -1,243 +1,243 @@ -<?php -/** - * OTP Spam Preventer View - * - * @package otpspampreventer/views - */ - -if ( ! defined( 'ABSPATH' ) ) { - exit; -} - -?> - -<div class="mo-osp-container"> - <form method="post" action="" id="mo_osp_settings_form"> - <?php wp_nonce_field( 'mo_osp_settings_save' ); ?> - <input type="hidden" name="option" value="mo_osp_settings_save" /> - - <div class="mo-header"> - <p class="mo-heading flex-1"> - <?php echo esc_html( __( 'OTP Spam Protection', 'miniorange-otp-verification' ) ); ?> - </p> - <input type="submit" name="save" id="save" class="mo-button inverted" value="<?php echo esc_attr( __( 'Save Settings', 'miniorange-otp-verification' ) ); ?>"> - </div> - - <div id="mo-osp-admin-notice-container"></div> - - <div class="mo-osp-addon-toggle-row"> - <label class="mo-osp-addon-toggle mo-osp-addon-toggle-emphasis" for="mo_osp_enabled"> - <input type="checkbox" id="mo_osp_enabled" name="mo_osp_enabled" value="1" <?php checked( ! empty( $settings['enabled'] ) ); ?> /> - <span><?php echo esc_html( __( 'Enable Addon', 'miniorange-otp-verification' ) ); ?></span> - </label> - </div> - - <div class="mo-osp-card"> - <div class="mo-osp-card-header"> - <h3 class="mo-osp-section-title"> - <svg class="mo-osp-section-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M12 2C13.1 2 14 2.9 14 4C14 5.1 13.1 6 12 6C10.9 6 10 5.1 10 4C10 2.9 10.9 2 12 2ZM21 9V7L15 4L13.5 7H7V9H13.5L15 12L21 9ZM4 15.5C4 17.43 5.57 19 7.5 19S11 17.43 11 15.5 9.43 12 7.5 12 4 13.57 4 15.5ZM7.5 17C6.67 17 6 16.33 6 15.5S6.67 14 7.5 14 9 14.67 9 15.5 8.33 17 7.5 17Z" fill="currentColor"/> - </svg> - <?php echo esc_html( __( 'Basic Protection Settings', 'miniorange-otp-verification' ) ); ?> - </h3> - <p class="mo-osp-section-desc"><?php echo esc_html( __( 'Configure how long users must wait between OTP requests and how many attempts are allowed.', 'miniorange-otp-verification' ) ); ?></p> - </div> - - <div class="mo-osp-card-body"> - <div class="mo-osp-fields-grid"> - <div class="mo-osp-field-group"> - <div class="mo-input-wrapper group"> - <label class="mo-input-label"> - <svg class="mo-osp-field-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2ZM17 13H11V7H12.5V11.5H17V13Z" fill="currentColor"/> - </svg> - <?php echo esc_html( __( 'Wait Time Between Requests', 'miniorange-otp-verification' ) ); ?> - </label> - <input type="number" id="mo_osp_cooldown_time" name="mo_osp_cooldown_time" value="<?php echo esc_attr( $settings['cooldown_time'] ); ?>" min="0" max="86400" class="mo-form-input w-full" /> - </div> - <p class="mo-osp-field-desc"><?php echo esc_html( __( 'Seconds users must wait before requesting another OTP (e.g., 60 = 1 minute).', 'miniorange-otp-verification' ) ); ?></p> - </div> - - <div class="mo-osp-field-group"> - <div class="mo-input-wrapper group"> - <label class="mo-input-label"> - <svg class="mo-osp-field-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M12 2C10.1 2 8.5 3.6 8.5 5.5S10.1 9 12 9 15.5 7.4 15.5 5.5 13.9 2 12 2ZM12 7C11.2 7 10.5 6.3 10.5 5.5S11.2 4 12 4 13.5 4.7 13.5 5.5 12.8 7 12 7ZM5.5 8C3.6 8 2 9.6 2 11.5S3.6 15 5.5 15 9 13.4 9 11.5 7.4 8 5.5 8ZM18.5 8C16.6 8 15 9.6 15 11.5S16.6 15 18.5 15 22 13.4 22 11.5 20.4 8 18.5 8ZM12 10.5C10.1 10.5 8.5 12.1 8.5 14S10.1 17.5 12 17.5 15.5 15.9 15.5 14 13.9 10.5 12 10.5ZM5.5 16C3.6 16 2 17.6 2 19.5S3.6 23 5.5 23 9 21.4 9 19.5 7.4 16 5.5 16ZM18.5 16C16.6 16 15 17.6 15 19.5S16.6 23 18.5 23 22 21.4 22 19.5 20.4 16 18.5 16Z" fill="currentColor"/> - </svg> - <?php echo esc_html( __( 'Maximum Attempts Allowed', 'miniorange-otp-verification' ) ); ?> - </label> - <input type="number" id="mo_osp_max_attempts" name="mo_osp_max_attempts" value="<?php echo esc_attr( $settings['max_attempts'] ); ?>" min="3" max="10" class="mo-form-input w-full" /> - </div> - <p class="mo-osp-field-desc"><?php echo esc_html( __( 'How many OTP requests are allowed before blocking (between 1-10).', 'miniorange-otp-verification' ) ); ?></p> - </div> - - <div class="mo-osp-field-group"> - <div class="mo-input-wrapper group"> - <label class="mo-input-label"> - <svg class="mo-osp-field-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M18 8H17V6C17 3.24 14.76 1 12 1S7 3.24 7 6V8H6C4.9 8 4 8.9 4 10V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V10C20 8.9 19.1 8 18 8ZM12 17C10.9 17 10 16.1 10 15S10.9 13 12 13 14 13.9 14 15 13.1 17 12 17ZM15.1 8H8.9V6C8.9 4.29 10.29 2.9 12 2.9S15.1 4.29 15.1 6V8Z" fill="currentColor"/> - </svg> - <?php echo esc_html( __( 'Block Duration', 'miniorange-otp-verification' ) ); ?> - </label> - <input type="number" id="mo_osp_block_time" name="mo_osp_block_time" value="<?php echo esc_attr( $settings['block_time'] ); ?>" min="60" max="604800" class="mo-form-input w-full" /> - </div> - <p class="mo-osp-field-desc"><?php echo esc_html( __( 'How long to block users after too many attempts in seconds (3600 = 1 hour).', 'miniorange-otp-verification' ) ); ?></p> - </div> - </div> - </div> - </div> - - <div class="mo-osp-card"> - <button type="button" id="mo-osp-toggle-advanced" class="mo-osp-toggle-btn"> - <div class="mo-osp-toggle-content"> - <h3 class="mo-osp-section-title"> - <svg class="mo-osp-section-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5A3.5 3.5 0 0 1 15.5 12A3.5 3.5 0 0 1 12 15.5M19.43 12.98C19.47 12.66 19.5 12.34 19.5 12S19.47 11.34 19.43 11.02L21.54 9.37C21.73 9.22 21.78 8.95 21.66 8.73L19.66 5.27C19.54 5.05 19.27 4.97 19.05 5.05L16.56 6.05C16.04 5.65 15.48 5.32 14.87 5.07L14.49 2.42C14.46 2.18 14.25 2 14 2H10C9.75 2 9.54 2.18 9.51 2.42L9.13 5.07C8.52 5.32 7.96 5.66 7.44 6.05L4.95 5.05C4.72 4.96 4.46 5.05 4.34 5.27L2.34 8.73C2.21 8.95 2.27 9.22 2.46 9.37L4.57 11.02C4.53 11.34 4.5 11.67 4.5 12S4.53 12.66 4.57 12.98L2.46 14.63C2.27 14.78 2.21 15.05 2.34 15.27L4.34 18.73C4.46 18.95 4.73 19.03 4.95 18.95L7.44 17.95C7.96 18.35 8.52 18.68 9.13 18.93L9.51 21.58C9.54 21.82 9.75 22 10 22H14C14.25 22 14.46 21.82 14.49 21.58L14.87 18.93C15.48 18.68 16.04 18.34 16.56 17.95L19.05 18.95C19.28 19.04 19.54 18.95 19.66 18.73L21.66 15.27C21.78 15.05 21.73 14.78 21.54 14.63L19.43 12.98Z" fill="currentColor"/> - </svg> - <?php echo esc_html( __( 'Advanced Settings', 'miniorange-otp-verification' ) ); ?> - </h3> - <div class="mo-osp-toggle-indicator"> - <span id="mo-osp-toggle-text" class="mo-osp-toggle-text"><?php echo esc_html( __( 'Show Advanced', 'miniorange-otp-verification' ) ); ?></span> - <span id="mo-osp-toggle-icon" class="mo-osp-toggle-icon">▼</span> - </div> - </div> - </button> - - <div id="mo-osp-advanced-settings" class="mo-osp-advanced-hidden"> - <div class="mo-osp-advanced-content"> - <div class="mo-osp-subsection"> - <div class="mo-osp-subsection-header"> - <h4 class="mo-osp-subsection-title"> - <svg class="mo-osp-subsection-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M11.99 2C6.47 2 2 6.48 2 12S6.47 22 11.99 22C17.52 22 22 17.52 22 12S17.52 2 11.99 2ZM12 20C7.58 20 4 16.42 4 12S7.58 4 12 4S20 7.58 20 12S16.42 20 12 20ZM12.5 7H11V13L16.25 16.15L17 14.92L12.5 12.25V7Z" fill="currentColor"/> - </svg> - <?php echo esc_html( __( 'Daily & Hourly Limits', 'miniorange-otp-verification' ) ); ?> - </h4> - <p class="mo-osp-section-desc"><?php echo esc_html( __( 'Set maximum OTP requests per user per day and per hour to prevent abuse.', 'miniorange-otp-verification' ) ); ?></p> - </div> - - <div class="mo-osp-fields-grid"> - <div class="mo-osp-field-group"> - <div class="mo-input-wrapper group"> - <label class="mo-input-label"> - <svg class="mo-osp-field-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M19 3H18V1H16V3H8V1H6V3H5C3.89 3 3.01 3.9 3.01 5L3 19C3 20.1 3.89 21 5 21H19C20.1 21 21 20.1 21 19V5C21 3.9 20.1 3 19 3ZM19 19H5V8H19V19ZM7 10H12V15H7V10Z" fill="currentColor"/> - </svg> - <?php echo esc_html( __( 'Daily Limit Per User', 'miniorange-otp-verification' ) ); ?> - </label> - <input type="number" id="mo_osp_daily_limit" name="mo_osp_daily_limit" value="<?php echo esc_attr( $settings['daily_limit'] ); ?>" min="1" max="1000" class="mo-form-input w-full" /> - </div> - <p class="mo-osp-field-desc"><?php echo esc_html( __( 'Maximum OTP requests one user can make in a single day.', 'miniorange-otp-verification' ) ); ?></p> - </div> - - <div class="mo-osp-field-group"> - <div class="mo-input-wrapper group"> - <label class="mo-input-label"> - <svg class="mo-osp-field-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M11.99 2C6.47 2 2 6.48 2 12S6.47 22 11.99 22C17.52 22 22 17.52 22 12S17.52 2 11.99 2ZM12 20C7.58 20 4 16.42 4 12S7.58 4 12 4S20 7.58 20 12S16.42 20 12 20ZM12.5 7H11V13L16.25 16.15L17 14.92L12.5 12.25V7Z" fill="currentColor"/> - </svg> - <?php echo esc_html( __( 'Hourly Limit Per User', 'miniorange-otp-verification' ) ); ?> - </label> - <input type="number" id="mo_osp_hourly_limit" name="mo_osp_hourly_limit" value="<?php echo esc_attr( $settings['hourly_limit'] ); ?>" min="1" max="100" class="mo-form-input w-full" /> - </div> - <p class="mo-osp-field-desc"><?php echo esc_html( __( 'Maximum OTP requests one user can make in a single hour.', 'miniorange-otp-verification' ) ); ?></p> - </div> - </div> - </div> - - <div class="mo-osp-subsection"> - <div class="mo-osp-subsection-header"> - <h4 class="mo-osp-subsection-title"> - <svg class="mo-osp-subsection-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M12 2C13.5 2 15 2.19 16.43 2.56L15.65 4.93C14.46 4.33 13.24 4 12 4C8.27 4 4.94 5.66 3 8.5C4.94 11.34 8.27 13 12 13S19.06 11.34 21 8.5C20.72 7.93 20.39 7.4 20.03 6.93L21.42 5.54C22.41 6.69 23.06 7.79 23.06 8.5C23.06 9.21 22.41 10.31 21.42 11.46C19.94 13.34 16.06 15 12 15S4.06 13.34 2.58 11.46C1.59 10.31 0.94 9.21 0.94 8.5C0.94 7.79 1.59 6.69 2.58 5.54C4.06 3.66 7.94 2 12 2ZM12 6.5C13.38 6.5 14.5 7.62 14.5 9S13.38 11.5 12 11.5 9.5 10.38 9.5 9 10.62 6.5 12 6.5ZM18.5 1L17 2.5L18.5 4L20 2.5L18.5 1Z" fill="currentColor"/> - </svg> - <?php echo esc_html( __( 'Trusted IP Addresses', 'miniorange-otp-verification' ) ); ?> - </h4> - <p class="mo-osp-section-desc"><?php echo esc_html( __( 'IP addresses that should never be blocked, even if they exceed limits.', 'miniorange-otp-verification' ) ); ?></p> - </div> - - <div class="mo-osp-field-group mo-osp-field-full"> - <div class="mo-input-wrapper group"> - <label class="mo-input-label"> - <svg class="mo-osp-field-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M4.93 4.93L3.51 6.34C2.52 7.33 2 8.61 2 10S2.52 12.67 3.51 13.66L6.34 16.49C7.33 17.48 8.61 18 10 18S12.67 17.48 13.66 16.49L16.49 13.66C17.48 12.67 18 11.39 18 10S17.48 7.33 16.49 6.34L13.66 3.51C12.67 2.52 11.39 2 10 2S7.33 2.52 6.34 3.51L4.93 4.93ZM15.07 9.07L13.66 10.49L12.24 9.07L10.83 10.49L12.24 11.9L10.83 13.32L12.24 14.73L13.66 13.32L15.07 14.73L16.49 13.32L15.07 11.9L16.49 10.49L15.07 9.07ZM8.41 8.41L9.83 7L11.24 8.41L12.66 7L14.07 8.41L12.66 9.83L11.24 8.41L9.83 9.83L8.41 8.41Z" fill="currentColor"/> - </svg> - <?php echo esc_html( __( 'Whitelist IP Addresses', 'miniorange-otp-verification' ) ); ?> - </label> - <textarea id="mo_osp_whitelist_ips" name="mo_osp_whitelist_ips" rows="6" class="mo-form-textarea w-full"> - <?php - $whitelist_display = isset( $settings['whitelist_ips'] ) && is_array( $settings['whitelist_ips'] ) - ? $settings['whitelist_ips'] - : ( is_string( $settings['whitelist_ips'] ) - ? array_filter( array_map( 'trim', explode( "\n", $settings['whitelist_ips'] ) ) ) - : array() ); - echo esc_textarea( implode( "\n", $whitelist_display ) ); - ?> - </textarea> - </div> - <p class="mo-osp-field-desc"><?php echo esc_html( __( 'Enter one IP address per line (e.g., 192.168.1.1). These IPs will bypass all protection.', 'miniorange-otp-verification' ) ); ?></p> - </div> - </div> - </div> - </div> - </div> - - <div class="mo-osp-card"> - <div class="mo-osp-card-header"> - <h3 class="mo-osp-section-title"> - <svg class="mo-osp-section-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M12 2C6.48 2 2 6.48 2 12S6.48 22 12 22 22 17.52 22 12 17.52 2 12 2ZM13 17H11V15H13V17ZM13 13H11V7H13V13Z" fill="currentColor"/> - </svg> - <?php echo esc_html( __( 'Blocked Users', 'miniorange-otp-verification' ) ); ?> - </h3> - <p class="mo-osp-section-desc"><?php echo esc_html( __( 'View and manage users who are currently blocked due to rate limits or excessive attempts.', 'miniorange-otp-verification' ) ); ?></p> - </div> - - <div class="mo-osp-card-body"> - <div id="mo-osp-blocked-users-container"> - <div class="mo-osp-loading" id="mo-osp-blocked-users-loading" style="display: none;"> - <p><?php echo esc_html( __( 'Loading blocked users...', 'miniorange-otp-verification' ) ); ?></p> - </div> - <div id="mo-osp-blocked-users-table-container"> - <table class="mo-osp-blocked-users-table" id="mo-osp-blocked-users-table"> - <thead> - <tr> - <th><?php echo esc_html( __( 'User Identifier', 'miniorange-otp-verification' ) ); ?></th> - <th><?php echo esc_html( __( 'Block Reason', 'miniorange-otp-verification' ) ); ?></th> - <th><?php echo esc_html( __( 'Remaining Time', 'miniorange-otp-verification' ) ); ?></th> - <th><?php echo esc_html( __( 'Actions', 'miniorange-otp-verification' ) ); ?></th> - </tr> - </thead> - <tbody id="mo-osp-blocked-users-tbody"> - <tr> - <td colspan="4" class="mo-osp-no-data"> - <?php echo esc_html( __( 'No blocked users found.', 'miniorange-otp-verification' ) ); ?> - </td> - </tr> - </tbody> - </table> - </div> - <div class="mo-osp-blocked-users-pagination" id="mo-osp-blocked-users-pagination" style="display: none;"> - <button type="button" class="mo-button mo-button-secondary" id="mo-osp-prev-page" disabled><?php echo esc_html( __( 'Previous', 'miniorange-otp-verification' ) ); ?></button> - <span id="mo-osp-page-info"></span> - <button type="button" class="mo-button mo-button-secondary" id="mo-osp-next-page" disabled><?php echo esc_html( __( 'Next', 'miniorange-otp-verification' ) ); ?></button> - </div> - <div class="mo-osp-blocked-users-actions"> - <button type="button" class="mo-button mo-button-secondary" id="mo-osp-clear-all-blocked-users"> - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M6 19C6 20.1 6.9 21 8 21H16C17.1 21 18 20.1 18 19V7H6V19ZM8 9H16V19H8V9ZM15.5 4L14.5 3H9.5L8.5 4H5V6H19V4H15.5Z" fill="currentColor"/> - </svg> - <?php echo esc_html( __( 'Clear All', 'miniorange-otp-verification' ) ); ?> - </button> - <button type="button" class="mo-button mo-button-secondary" id="mo-osp-refresh-blocked-users"> - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M17.65 6.35C16.2 4.9 14.21 4 12 4C7.58 4 4 7.58 4 12S7.58 20 12 20C15.73 20 18.84 17.45 19.73 14H17.65C16.83 16.33 14.61 18 12 18C8.69 18 6 15.31 6 12S8.69 6 12 6C13.66 6 15.14 6.69 16.22 7.78L13 11H20V4L17.65 6.35Z" fill="currentColor"/> - </svg> - <?php echo esc_html( __( 'Refresh List', 'miniorange-otp-verification' ) ); ?> - </button> - </div> - </div> - </div> - </div> - </form> -</div> +<?php +/** + * OTP Spam Preventer View + * + * @package otpspampreventer/views + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +?> + +<div class="mo-osp-container"> + <form method="post" action="" id="mo_osp_settings_form"> + <?php wp_nonce_field( 'mo_osp_settings_save' ); ?> + <input type="hidden" name="option" value="mo_osp_settings_save" /> + + <div class="mo-header"> + <p class="mo-heading flex-1"> + <?php echo esc_html( __( 'OTP Spam Protection', 'miniorange-otp-verification' ) ); ?> + </p> + <input type="submit" name="save" id="save" class="mo-button inverted" value="<?php echo esc_attr( __( 'Save Settings', 'miniorange-otp-verification' ) ); ?>"> + </div> + + <div id="mo-osp-admin-notice-container"></div> + + <div class="mo-osp-addon-toggle-row"> + <label class="mo-osp-addon-toggle mo-osp-addon-toggle-emphasis" for="mo_osp_enabled"> + <input type="checkbox" id="mo_osp_enabled" name="mo_osp_enabled" value="1" <?php checked( ! empty( $settings['enabled'] ) ); ?> /> + <span><?php echo esc_html( __( 'Enable Addon', 'miniorange-otp-verification' ) ); ?></span> + </label> + </div> + + <div class="mo-osp-card"> + <div class="mo-osp-card-header"> + <h3 class="mo-osp-section-title"> + <svg class="mo-osp-section-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M12 2C13.1 2 14 2.9 14 4C14 5.1 13.1 6 12 6C10.9 6 10 5.1 10 4C10 2.9 10.9 2 12 2ZM21 9V7L15 4L13.5 7H7V9H13.5L15 12L21 9ZM4 15.5C4 17.43 5.57 19 7.5 19S11 17.43 11 15.5 9.43 12 7.5 12 4 13.57 4 15.5ZM7.5 17C6.67 17 6 16.33 6 15.5S6.67 14 7.5 14 9 14.67 9 15.5 8.33 17 7.5 17Z" fill="currentColor"/> + </svg> + <?php echo esc_html( __( 'Basic Protection Settings', 'miniorange-otp-verification' ) ); ?> + </h3> + <p class="mo-osp-section-desc"><?php echo esc_html( __( 'Configure how long users must wait between OTP requests and how many attempts are allowed.', 'miniorange-otp-verification' ) ); ?></p> + </div> + + <div class="mo-osp-card-body"> + <div class="mo-osp-fields-grid"> + <div class="mo-osp-field-group"> + <div class="mo-input-wrapper group"> + <label class="mo-input-label"> + <svg class="mo-osp-field-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2ZM17 13H11V7H12.5V11.5H17V13Z" fill="currentColor"/> + </svg> + <?php echo esc_html( __( 'Wait Time Between Requests', 'miniorange-otp-verification' ) ); ?> + </label> + <input type="number" id="mo_osp_cooldown_time" name="mo_osp_cooldown_time" value="<?php echo esc_attr( $settings['cooldown_time'] ); ?>" min="0" max="86400" class="mo-form-input w-full" /> + </div> + <p class="mo-osp-field-desc"><?php echo esc_html( __( 'Seconds users must wait before requesting another OTP (e.g., 60 = 1 minute).', 'miniorange-otp-verification' ) ); ?></p> + </div> + + <div class="mo-osp-field-group"> + <div class="mo-input-wrapper group"> + <label class="mo-input-label"> + <svg class="mo-osp-field-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M12 2C10.1 2 8.5 3.6 8.5 5.5S10.1 9 12 9 15.5 7.4 15.5 5.5 13.9 2 12 2ZM12 7C11.2 7 10.5 6.3 10.5 5.5S11.2 4 12 4 13.5 4.7 13.5 5.5 12.8 7 12 7ZM5.5 8C3.6 8 2 9.6 2 11.5S3.6 15 5.5 15 9 13.4 9 11.5 7.4 8 5.5 8ZM18.5 8C16.6 8 15 9.6 15 11.5S16.6 15 18.5 15 22 13.4 22 11.5 20.4 8 18.5 8ZM12 10.5C10.1 10.5 8.5 12.1 8.5 14S10.1 17.5 12 17.5 15.5 15.9 15.5 14 13.9 10.5 12 10.5ZM5.5 16C3.6 16 2 17.6 2 19.5S3.6 23 5.5 23 9 21.4 9 19.5 7.4 16 5.5 16ZM18.5 16C16.6 16 15 17.6 15 19.5S16.6 23 18.5 23 22 21.4 22 19.5 20.4 16 18.5 16Z" fill="currentColor"/> + </svg> + <?php echo esc_html( __( 'Maximum Attempts Allowed', 'miniorange-otp-verification' ) ); ?> + </label> + <input type="number" id="mo_osp_max_attempts" name="mo_osp_max_attempts" value="<?php echo esc_attr( $settings['max_attempts'] ); ?>" min="3" max="10" class="mo-form-input w-full" /> + </div> + <p class="mo-osp-field-desc"><?php echo esc_html( __( 'How many OTP requests are allowed before blocking (between 1-10).', 'miniorange-otp-verification' ) ); ?></p> + </div> + + <div class="mo-osp-field-group"> + <div class="mo-input-wrapper group"> + <label class="mo-input-label"> + <svg class="mo-osp-field-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M18 8H17V6C17 3.24 14.76 1 12 1S7 3.24 7 6V8H6C4.9 8 4 8.9 4 10V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V10C20 8.9 19.1 8 18 8ZM12 17C10.9 17 10 16.1 10 15S10.9 13 12 13 14 13.9 14 15 13.1 17 12 17ZM15.1 8H8.9V6C8.9 4.29 10.29 2.9 12 2.9S15.1 4.29 15.1 6V8Z" fill="currentColor"/> + </svg> + <?php echo esc_html( __( 'Block Duration', 'miniorange-otp-verification' ) ); ?> + </label> + <input type="number" id="mo_osp_block_time" name="mo_osp_block_time" value="<?php echo esc_attr( $settings['block_time'] ); ?>" min="60" max="604800" class="mo-form-input w-full" /> + </div> + <p class="mo-osp-field-desc"><?php echo esc_html( __( 'How long to block users after too many attempts in seconds (3600 = 1 hour).', 'miniorange-otp-verification' ) ); ?></p> + </div> + </div> + </div> + </div> + + <div class="mo-osp-card"> + <button type="button" id="mo-osp-toggle-advanced" class="mo-osp-toggle-btn"> + <div class="mo-osp-toggle-content"> + <h3 class="mo-osp-section-title"> + <svg class="mo-osp-section-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5A3.5 3.5 0 0 1 15.5 12A3.5 3.5 0 0 1 12 15.5M19.43 12.98C19.47 12.66 19.5 12.34 19.5 12S19.47 11.34 19.43 11.02L21.54 9.37C21.73 9.22 21.78 8.95 21.66 8.73L19.66 5.27C19.54 5.05 19.27 4.97 19.05 5.05L16.56 6.05C16.04 5.65 15.48 5.32 14.87 5.07L14.49 2.42C14.46 2.18 14.25 2 14 2H10C9.75 2 9.54 2.18 9.51 2.42L9.13 5.07C8.52 5.32 7.96 5.66 7.44 6.05L4.95 5.05C4.72 4.96 4.46 5.05 4.34 5.27L2.34 8.73C2.21 8.95 2.27 9.22 2.46 9.37L4.57 11.02C4.53 11.34 4.5 11.67 4.5 12S4.53 12.66 4.57 12.98L2.46 14.63C2.27 14.78 2.21 15.05 2.34 15.27L4.34 18.73C4.46 18.95 4.73 19.03 4.95 18.95L7.44 17.95C7.96 18.35 8.52 18.68 9.13 18.93L9.51 21.58C9.54 21.82 9.75 22 10 22H14C14.25 22 14.46 21.82 14.49 21.58L14.87 18.93C15.48 18.68 16.04 18.34 16.56 17.95L19.05 18.95C19.28 19.04 19.54 18.95 19.66 18.73L21.66 15.27C21.78 15.05 21.73 14.78 21.54 14.63L19.43 12.98Z" fill="currentColor"/> + </svg> + <?php echo esc_html( __( 'Advanced Settings', 'miniorange-otp-verification' ) ); ?> + </h3> + <div class="mo-osp-toggle-indicator"> + <span id="mo-osp-toggle-text" class="mo-osp-toggle-text"><?php echo esc_html( __( 'Show Advanced', 'miniorange-otp-verification' ) ); ?></span> + <span id="mo-osp-toggle-icon" class="mo-osp-toggle-icon">▼</span> + </div> + </div> + </button> + + <div id="mo-osp-advanced-settings" class="mo-osp-advanced-hidden"> + <div class="mo-osp-advanced-content"> + <div class="mo-osp-subsection"> + <div class="mo-osp-subsection-header"> + <h4 class="mo-osp-subsection-title"> + <svg class="mo-osp-subsection-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M11.99 2C6.47 2 2 6.48 2 12S6.47 22 11.99 22C17.52 22 22 17.52 22 12S17.52 2 11.99 2ZM12 20C7.58 20 4 16.42 4 12S7.58 4 12 4S20 7.58 20 12S16.42 20 12 20ZM12.5 7H11V13L16.25 16.15L17 14.92L12.5 12.25V7Z" fill="currentColor"/> + </svg> + <?php echo esc_html( __( 'Daily & Hourly Limits', 'miniorange-otp-verification' ) ); ?> + </h4> + <p class="mo-osp-section-desc"><?php echo esc_html( __( 'Set maximum OTP requests per user per day and per hour to prevent abuse.', 'miniorange-otp-verification' ) ); ?></p> + </div> + + <div class="mo-osp-fields-grid"> + <div class="mo-osp-field-group"> + <div class="mo-input-wrapper group"> + <label class="mo-input-label"> + <svg class="mo-osp-field-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M19 3H18V1H16V3H8V1H6V3H5C3.89 3 3.01 3.9 3.01 5L3 19C3 20.1 3.89 21 5 21H19C20.1 21 21 20.1 21 19V5C21 3.9 20.1 3 19 3ZM19 19H5V8H19V19ZM7 10H12V15H7V10Z" fill="currentColor"/> + </svg> + <?php echo esc_html( __( 'Daily Limit Per User', 'miniorange-otp-verification' ) ); ?> + </label> + <input type="number" id="mo_osp_daily_limit" name="mo_osp_daily_limit" value="<?php echo esc_attr( $settings['daily_limit'] ); ?>" min="1" max="1000" class="mo-form-input w-full" /> + </div> + <p class="mo-osp-field-desc"><?php echo esc_html( __( 'Maximum OTP requests one user can make in a single day.', 'miniorange-otp-verification' ) ); ?></p> + </div> + + <div class="mo-osp-field-group"> + <div class="mo-input-wrapper group"> + <label class="mo-input-label"> + <svg class="mo-osp-field-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M11.99 2C6.47 2 2 6.48 2 12S6.47 22 11.99 22C17.52 22 22 17.52 22 12S17.52 2 11.99 2ZM12 20C7.58 20 4 16.42 4 12S7.58 4 12 4S20 7.58 20 12S16.42 20 12 20ZM12.5 7H11V13L16.25 16.15L17 14.92L12.5 12.25V7Z" fill="currentColor"/> + </svg> + <?php echo esc_html( __( 'Hourly Limit Per User', 'miniorange-otp-verification' ) ); ?> + </label> + <input type="number" id="mo_osp_hourly_limit" name="mo_osp_hourly_limit" value="<?php echo esc_attr( $settings['hourly_limit'] ); ?>" min="1" max="100" class="mo-form-input w-full" /> + </div> + <p class="mo-osp-field-desc"><?php echo esc_html( __( 'Maximum OTP requests one user can make in a single hour.', 'miniorange-otp-verification' ) ); ?></p> + </div> + </div> + </div> + + <div class="mo-osp-subsection"> + <div class="mo-osp-subsection-header"> + <h4 class="mo-osp-subsection-title"> + <svg class="mo-osp-subsection-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M12 2C13.5 2 15 2.19 16.43 2.56L15.65 4.93C14.46 4.33 13.24 4 12 4C8.27 4 4.94 5.66 3 8.5C4.94 11.34 8.27 13 12 13S19.06 11.34 21 8.5C20.72 7.93 20.39 7.4 20.03 6.93L21.42 5.54C22.41 6.69 23.06 7.79 23.06 8.5C23.06 9.21 22.41 10.31 21.42 11.46C19.94 13.34 16.06 15 12 15S4.06 13.34 2.58 11.46C1.59 10.31 0.94 9.21 0.94 8.5C0.94 7.79 1.59 6.69 2.58 5.54C4.06 3.66 7.94 2 12 2ZM12 6.5C13.38 6.5 14.5 7.62 14.5 9S13.38 11.5 12 11.5 9.5 10.38 9.5 9 10.62 6.5 12 6.5ZM18.5 1L17 2.5L18.5 4L20 2.5L18.5 1Z" fill="currentColor"/> + </svg> + <?php echo esc_html( __( 'Trusted IP Addresses', 'miniorange-otp-verification' ) ); ?> + </h4> + <p class="mo-osp-section-desc"><?php echo esc_html( __( 'IP addresses that should never be blocked, even if they exceed limits.', 'miniorange-otp-verification' ) ); ?></p> + </div> + + <div class="mo-osp-field-group mo-osp-field-full"> + <div class="mo-input-wrapper group"> + <label class="mo-input-label"> + <svg class="mo-osp-field-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M4.93 4.93L3.51 6.34C2.52 7.33 2 8.61 2 10S2.52 12.67 3.51 13.66L6.34 16.49C7.33 17.48 8.61 18 10 18S12.67 17.48 13.66 16.49L16.49 13.66C17.48 12.67 18 11.39 18 10S17.48 7.33 16.49 6.34L13.66 3.51C12.67 2.52 11.39 2 10 2S7.33 2.52 6.34 3.51L4.93 4.93ZM15.07 9.07L13.66 10.49L12.24 9.07L10.83 10.49L12.24 11.9L10.83 13.32L12.24 14.73L13.66 13.32L15.07 14.73L16.49 13.32L15.07 11.9L16.49 10.49L15.07 9.07ZM8.41 8.41L9.83 7L11.24 8.41L12.66 7L14.07 8.41L12.66 9.83L11.24 8.41L9.83 9.83L8.41 8.41Z" fill="currentColor"/> + </svg> + <?php echo esc_html( __( 'Whitelist IP Addresses', 'miniorange-otp-verification' ) ); ?> + </label> + <textarea id="mo_osp_whitelist_ips" name="mo_osp_whitelist_ips" rows="6" class="mo-form-textarea w-full"> + <?php + $whitelist_display = isset( $settings['whitelist_ips'] ) && is_array( $settings['whitelist_ips'] ) + ? $settings['whitelist_ips'] + : ( is_string( $settings['whitelist_ips'] ) + ? array_filter( array_map( 'trim', explode( "\n", $settings['whitelist_ips'] ) ) ) + : array() ); + echo esc_textarea( implode( "\n", $whitelist_display ) ); + ?> + </textarea> + </div> + <p class="mo-osp-field-desc"><?php echo esc_html( __( 'Enter one IP address per line (e.g., 192.168.1.1). These IPs will bypass all protection.', 'miniorange-otp-verification' ) ); ?></p> + </div> + </div> + </div> + </div> + </div> + + <div class="mo-osp-card"> + <div class="mo-osp-card-header"> + <h3 class="mo-osp-section-title"> + <svg class="mo-osp-section-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M12 2C6.48 2 2 6.48 2 12S6.48 22 12 22 22 17.52 22 12 17.52 2 12 2ZM13 17H11V15H13V17ZM13 13H11V7H13V13Z" fill="currentColor"/> + </svg> + <?php echo esc_html( __( 'Blocked Users', 'miniorange-otp-verification' ) ); ?> + </h3> + <p class="mo-osp-section-desc"><?php echo esc_html( __( 'View and manage users who are currently blocked due to rate limits or excessive attempts.', 'miniorange-otp-verification' ) ); ?></p> + </div> + + <div class="mo-osp-card-body"> + <div id="mo-osp-blocked-users-container"> + <div class="mo-osp-loading" id="mo-osp-blocked-users-loading" style="display: none;"> + <p><?php echo esc_html( __( 'Loading blocked users...', 'miniorange-otp-verification' ) ); ?></p> + </div> + <div id="mo-osp-blocked-users-table-container"> + <table class="mo-osp-blocked-users-table" id="mo-osp-blocked-users-table"> + <thead> + <tr> + <th><?php echo esc_html( __( 'User Identifier', 'miniorange-otp-verification' ) ); ?></th> + <th><?php echo esc_html( __( 'Block Reason', 'miniorange-otp-verification' ) ); ?></th> + <th><?php echo esc_html( __( 'Remaining Time', 'miniorange-otp-verification' ) ); ?></th> + <th><?php echo esc_html( __( 'Actions', 'miniorange-otp-verification' ) ); ?></th> + </tr> + </thead> + <tbody id="mo-osp-blocked-users-tbody"> + <tr> + <td colspan="4" class="mo-osp-no-data"> + <?php echo esc_html( __( 'No blocked users found.', 'miniorange-otp-verification' ) ); ?> + </td> + </tr> + </tbody> + </table> + </div> + <div class="mo-osp-blocked-users-pagination" id="mo-osp-blocked-users-pagination" style="display: none;"> + <button type="button" class="mo-button mo-button-secondary" id="mo-osp-prev-page" disabled><?php echo esc_html( __( 'Previous', 'miniorange-otp-verification' ) ); ?></button> + <span id="mo-osp-page-info"></span> + <button type="button" class="mo-button mo-button-secondary" id="mo-osp-next-page" disabled><?php echo esc_html( __( 'Next', 'miniorange-otp-verification' ) ); ?></button> + </div> + <div class="mo-osp-blocked-users-actions"> + <button type="button" class="mo-button mo-button-secondary" id="mo-osp-clear-all-blocked-users"> + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M6 19C6 20.1 6.9 21 8 21H16C17.1 21 18 20.1 18 19V7H6V19ZM8 9H16V19H8V9ZM15.5 4L14.5 3H9.5L8.5 4H5V6H19V4H15.5Z" fill="currentColor"/> + </svg> + <?php echo esc_html( __( 'Clear All', 'miniorange-otp-verification' ) ); ?> + </button> + <button type="button" class="mo-button mo-button-secondary" id="mo-osp-refresh-blocked-users"> + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M17.65 6.35C16.2 4.9 14.21 4 12 4C7.58 4 4 7.58 4 12S7.58 20 12 20C15.73 20 18.84 17.45 19.73 14H17.65C16.83 16.33 14.61 18 12 18C8.69 18 6 15.31 6 12S8.69 6 12 6C13.66 6 15.14 6.69 16.22 7.78L13 11H20V4L17.65 6.35Z" fill="currentColor"/> + </svg> + <?php echo esc_html( __( 'Refresh List', 'miniorange-otp-verification' ) ); ?> + </button> + </div> + </div> + </div> + </div> + </form> +</div> Only in /home/deploy/wp-safety.org/data/plugin-versions/miniorange-otp-verification/5.5.2: api @@ -1,272 +1,272 @@ -<?php -/** - * Initializes plugin data. - * Contains defination of common functions. - * - * @package miniorange-otp-verification - */ - -if ( ! defined( 'ABSPATH' ) ) { - exit; -} - -use OTP\Helper\FormList; -use OTP\Helper\FormSessionData; -use OTP\Helper\MoUtility; -use OTP\Objects\FormHandler; -use OTP\Objects\IFormHandler; -use OTP\MoOTPSplClassLoader; -use OTP\LicenseLibrary\Classes\Mo_License_Library; -use OTP\Helper\MoConstants; - -define( 'MOV_DIR', plugin_dir_path( __FILE__ ) ); -define( 'MOV_URL', plugin_dir_url( __FILE__ ) ); - -$package_data = json_decode( initialize_package_json() ); - -define( 'MOV_VERSION', $package_data->version ); -define( 'MOV_TYPE', $package_data->type ); -define( 'MOV_HOST', $package_data->hostname ); -define( 'MOV_PORTAL', $package_data->portal ); -define( 'MOV_DEFAULT_CUSTOMERKEY', $package_data->dcustomerkey ); -define( 'MOV_DEFAULT_APIKEY', $package_data->dapikey ); -define( 'MOV_SSL_VERIFY', $package_data->sslverify ); -define( 'MOV_CSS_URL', MOV_URL . 'includes/css/mo_customer_validation_style.css?version=' . MOV_VERSION ); -define( 'MOV_FORM_CSS', MOV_URL . 'includes/css/mo_forms_css.css?version=' . MOV_VERSION ); -define( 'MO_INTTELINPUT_CSS', MOV_URL . 'includes/css/intlTelInput.min.css?version=' . MOV_VERSION ); -define( 'MOV_JS_URL', MOV_URL . 'includes/js/settings.js?version=' . MOV_VERSION ); -define( 'MOV_FEEDBACK_JS', MOV_URL . 'includes/js/mo_feedback.js?version=' . MOV_VERSION ); -define( 'VALIDATION_JS_URL', MOV_URL . 'includes/js/formValidation.js?version=' . MOV_VERSION ); -define( 'MO_INTTELINPUT_JS', MOV_URL . 'includes/js/intlTelInput.min.js?version=' . MOV_VERSION ); -define( 'MO_DROPDOWN_JS', MOV_URL . 'includes/js/dropdown.js?version=' . MOV_VERSION ); -define( 'MOV_LOADER_URL', MOV_URL . 'includes/images/loader.gif' ); -define( 'MOV_DONATE', MOV_URL . 'includes/images/donate.png' ); -define( 'MOV_PAYPAL', MOV_URL . 'includes/images/paypal.png' ); -define( 'MOV_WHATSAPP', MOV_URL . 'includes/images/tourIcons/whatsApp.svg' ); -define( 'MOV_NETBANK', MOV_URL . 'includes/images/netbanking.png' ); -define( 'MOV_CARD', MOV_URL . 'includes/images/card.png' ); -define( 'MOV_LOGO_URL', MOV_URL . 'includes/images/logo.png' ); -define( 'MOV_ICON', MOV_URL . 'includes/images/miniorange_icon.png' ); -define( 'MOV_ICON_GIF', MOV_URL . 'includes/images/mo_icon.gif' ); -define( 'MO_CUSTOM_FORM', MOV_URL . 'includes/js/customForm.js?version=' . MOV_VERSION ); -define( 'MOV_ADDON_DIR', MOV_DIR . 'addons/' ); -define( 'MOV_USE_POLYLANG', true ); -define( 'MO_TEST_MODE', $package_data->testmode ); -define( 'MO_FAIL_MODE', $package_data->failmode ); -define( 'MOV_SESSION_TYPE', $package_data->session ); -define( 'MOV_MAIL_LOGO', MOV_URL . 'includes/images/mo_support_icon.png' ); -define( 'MOV_OFFERS_LOGO', MOV_URL . 'includes/images/mo_sale_icon.png' ); -define( 'MOV_FEATURES_GRAPHIC', MOV_URL . 'includes/images/mo_features_graphic.png' ); -define( 'MOV_TYPE_PLAN', $package_data->typeplan ); -define( 'MOV_LICENSE_NAME', $package_data->licensename ); -define( 'MOV_CSS', MOV_URL . 'includes/css/mo_feedback_notice.css?version=' . MOV_VERSION ); -define( 'MOV_MAIN_CSS', MOV_URL . 'includes/css/mo-main.css?version=' . MOV_VERSION ); - -$target = realpath( __DIR__ . '/class-mootpsplclassloader.php' ); -if ( false !== $target && 0 === strpos( $target, realpath( MOV_DIR ) ) ) { - require $target; -} - -$idp_class_loader = new MoOTPSplClassLoader( 'OTP', realpath( __DIR__ . DIRECTORY_SEPARATOR . '..' ) ); -$idp_class_loader->register(); -$common_elements_path = realpath( __DIR__ . '/views/common-elements.php' ); -if ( false !== $common_elements_path && 0 === strpos( $common_elements_path, realpath( MOV_DIR ) ) ) { - require $common_elements_path; -} - -if ( file_exists( MOV_DIR . MoConstants::LICENCE_SERVICE_FILE ) ) { - new Mo_License_Library(); -} - - -/** - * Initializes handlers of forms. - * - * @return void - */ -function mo_initialize_forms() { - $forms_dir = MOV_DIR . 'handler/forms'; - $forms_path = realpath( $forms_dir ); - - if ( false === $forms_path || 0 !== strpos( $forms_path, realpath( MOV_DIR ) ) ) { - return; - } - - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator( $forms_path, RecursiveDirectoryIterator::SKIP_DOTS ), - RecursiveIteratorIterator::LEAVES_ONLY - ); - - $handler_list = FormList::instance(); - $is_block_checkout = mo_is_block_based_checkout(); - - foreach ( $iterator as $it ) { - $filename = $it->getFilename(); - $filename = sanitize_file_name( str_replace( 'class-', '', $filename ) ); - - if ( 'mowccheckoutnew.php' === $filename || 'woocommercecheckoutform.php' === $filename ) { - // Load only one WooCommerce checkout handler based on whether block-based checkout is enabled. - $selected_file = $is_block_checkout ? 'mowccheckoutnew.php' : 'woocommercecheckoutform.php'; - - // If the current file is not the selected handler, skip it to avoid duplicate registration. - if ( $filename !== $selected_file ) { - continue; - } - - $class_name = 'OTP\\Handler\\Forms\\' . str_replace( '.php', '', $selected_file ); - } else { - $class_name = 'OTP\\Handler\\Forms\\' . str_replace( '.php', '', $filename ); - } - - if ( class_exists( $class_name ) && method_exists( $class_name, 'instance' ) ) { - $form_handler = $class_name::instance(); - $handler_list->add( $form_handler->get_form_key(), $form_handler ); - } - } -} - -/** - * Returns if block checkout is enabled. - * - * This version does not rely on WooCommerce Blocks PHP classes being loaded. - * Instead, it inspects the WooCommerce Checkout page content for the - * `woocommerce/checkout` block, so it can be safely called early (e.g. in - * constructors) without depending on plugin load order. - * - * @return bool - */ -function mo_is_block_based_checkout() { - if ( ! function_exists( 'is_plugin_active' ) ) { - include_once ABSPATH . 'wp-admin/includes/plugin.php'; - } - - if ( ! is_plugin_active( 'woocommerce/woocommerce.php' ) ) { - return false; - } - - // Get the Checkout page ID directly from WooCommerce options. - $checkout_page_id = (int) get_option( 'woocommerce_checkout_page_id' ); - if ( $checkout_page_id <= 0 ) { - return false; - } - - $checkout_page = get_post( $checkout_page_id ); - if ( ! $checkout_page || empty( $checkout_page->post_content ) ) { - return false; - } - - // Prefer core has_block() if available (WP 5+). - if ( function_exists( 'has_block' ) ) { - return has_block( 'woocommerce/checkout', $checkout_page ); - } - - // Fallback: simple string search for the block name in post content. - return ( false !== strpos( $checkout_page->post_content, 'woocommerce/checkout' ) ); -} - -/** - * Returns admin post url. - * - * @return string - */ -function admin_post_url() { - return admin_url( 'admin-post.php' ); } - -/** - * Returns wp ajax url. - * - * @return string - */ -function wp_ajax_url() { - return admin_url( 'admin-ajax.php' ); } - -/** - * Escapes a string based on the type. - * - * @param string $string_value - string to be escaped. - * @param string $type - type of escaping to apply. - * @return string - */ -function mo_esc_string( $string_value, $type ) { - if ( 'attr' === $type ) { - return esc_attr( $string_value ); - } elseif ( 'url' === $type ) { - return esc_url( $string_value ); - } - - return esc_attr( $string_value ); -} - -/** - * Retrieves the value of the option from the wp_option table. - * - * @param string $string_value - option name to be retrieved. - * @param string $prefix - prefix of the option. - * @return mixed - */ -function get_mo_option( $string_value, $prefix = null ) { - $string_value = ( null === $prefix ? 'mo_customer_validation_' : $prefix ) . $string_value; - return apply_filters( 'get_mo_option', get_site_option( $string_value ) ); -} - -/** - * Updates the option set in the wp_option table. - * - * @param string $string_value - option name to be deleted. - * @param string $value - value of the option. - * @param string $prefix - prefix of the option. - */ -function update_mo_option( $string_value, $value, $prefix = null ) { - $string_value = ( null === $prefix ? 'mo_customer_validation_' : $prefix ) . $string_value; - update_site_option( $string_value, apply_filters( 'update_mo_option', $value, $string_value ) ); -} - -/** - * Deletes the option set in the wp_option table. - * - * @param string $string_value - option name to be deleted. - * @param string $prefix - prefix of the option. - */ -function delete_mo_option( $string_value, $prefix = null ) { - $string_value = ( null === $prefix ? 'mo_customer_validation_' : $prefix ) . $string_value; - delete_site_option( $string_value ); -} - -/** - * Returns the class name without namespace. - * - * @param object $obj - object of the class. - * @return string - */ -function get_mo_class( $obj ) { - $namespace_class = get_class( $obj ); - return substr( $namespace_class, strrpos( $namespace_class, '\\' ) + 1 ); -} - -/** - * To check if package.json file can be found through WP site URL or not. - * BuildScript.php updates the package.json file content in the below function instead of package.json to be used further in autoload.php - * example package.json string ["name"=>"miniorange-otp-verification","version"=>"5.5.1","type"=>"MiniOrangeGateway","testMode"=>false,"failMode"=>false,"hostname"=>"https:\/\/login.xecurify.com","dCustomerKey"=>"16555","dApiKey"=>"fFd2XcvTGDemZvbw1bcUesNJWEqKbbUq","sslVerify"=>true,"session"=>"AUTO"] - * - * @return string - */ -function initialize_package_json() { - $package = wp_json_encode( - array( - 'name' => 'miniorange-otp-verification', - 'version' => '5.5.1', - 'type' => 'MiniOrangeGateway', - 'testmode' => false, - 'failmode' => false, - 'hostname' => 'https://login.xecurify.com', - 'portal' => 'https://portal.miniorange.com', - 'dcustomerkey' => '16555', - 'dapikey' => 'fFd2XcvTGDemZvbw1bcUesNJWEqKbbUq', - 'sslverify' => true, - 'session' => 'AUTO', - 'typeplan' => 'wp_otp_verification_basic_plan', - 'licensename' => 'WP_OTP_VERIFICATION_PLUGIN', - ) - ); - return $package; -} +<?php +/** + * Initializes plugin data. + * Contains defination of common functions. + * + * @package miniorange-otp-verification + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +use OTP\Helper\FormList; +use OTP\Helper\FormSessionData; +use OTP\Helper\MoUtility; +use OTP\Objects\FormHandler; +use OTP\Objects\IFormHandler; +use OTP\MoOTPSplClassLoader; +use OTP\LicenseLibrary\Classes\Mo_License_Library; +use OTP\Helper\MoConstants; + +define( 'MOV_DIR', plugin_dir_path( __FILE__ ) ); +define( 'MOV_URL', plugin_dir_url( __FILE__ ) ); + +$package_data = json_decode( initialize_package_json() ); + +define( 'MOV_VERSION', $package_data->version ); +define( 'MOV_TYPE', $package_data->type ); +define( 'MOV_HOST', $package_data->hostname ); +define( 'MOV_PORTAL', $package_data->portal ); +define( 'MOV_DEFAULT_CUSTOMERKEY', $package_data->dcustomerkey ); +define( 'MOV_DEFAULT_APIKEY', $package_data->dapikey ); +define( 'MOV_SSL_VERIFY', $package_data->sslverify ); +define( 'MOV_CSS_URL', MOV_URL . 'includes/css/mo_customer_validation_style.css?version=' . MOV_VERSION ); +define( 'MOV_FORM_CSS', MOV_URL . 'includes/css/mo_forms_css.css?version=' . MOV_VERSION ); +define( 'MO_INTTELINPUT_CSS', MOV_URL . 'includes/css/intlTelInput.min.css?version=' . MOV_VERSION ); +define( 'MOV_JS_URL', MOV_URL . 'includes/js/settings.js?version=' . MOV_VERSION ); +define( 'MOV_FEEDBACK_JS', MOV_URL . 'includes/js/mo_feedback.js?version=' . MOV_VERSION ); +define( 'VALIDATION_JS_URL', MOV_URL . 'includes/js/formValidation.js?version=' . MOV_VERSION ); +define( 'MO_INTTELINPUT_JS', MOV_URL . 'includes/js/intlTelInput.min.js?version=' . MOV_VERSION ); +define( 'MO_DROPDOWN_JS', MOV_URL . 'includes/js/dropdown.js?version=' . MOV_VERSION ); +define( 'MOV_LOADER_URL', MOV_URL . 'includes/images/loader.gif' ); +define( 'MOV_DONATE', MOV_URL . 'includes/images/donate.png' ); +define( 'MOV_PAYPAL', MOV_URL . 'includes/images/paypal.png' ); +define( 'MOV_WHATSAPP', MOV_URL . 'includes/images/tourIcons/whatsApp.svg' ); +define( 'MOV_NETBANK', MOV_URL . 'includes/images/netbanking.png' ); +define( 'MOV_CARD', MOV_URL . 'includes/images/card.png' ); +define( 'MOV_LOGO_URL', MOV_URL . 'includes/images/logo.png' ); +define( 'MOV_ICON', MOV_URL . 'includes/images/miniorange_icon.png' ); +define( 'MOV_ICON_GIF', MOV_URL . 'includes/images/mo_icon.gif' ); +define( 'MO_CUSTOM_FORM', MOV_URL . 'includes/js/customForm.js?version=' . MOV_VERSION ); +define( 'MOV_ADDON_DIR', MOV_DIR . 'addons/' ); +define( 'MOV_USE_POLYLANG', true ); +define( 'MO_TEST_MODE', $package_data->testmode ); +define( 'MO_FAIL_MODE', $package_data->failmode ); +define( 'MOV_SESSION_TYPE', $package_data->session ); +define( 'MOV_MAIL_LOGO', MOV_URL . 'includes/images/mo_support_icon.png' ); +define( 'MOV_OFFERS_LOGO', MOV_URL . 'includes/images/mo_sale_icon.png' ); +define( 'MOV_FEATURES_GRAPHIC', MOV_URL . 'includes/images/mo_features_graphic.png' ); +define( 'MOV_TYPE_PLAN', $package_data->typeplan ); +define( 'MOV_LICENSE_NAME', $package_data->licensename ); +define( 'MOV_CSS', MOV_URL . 'includes/css/mo_feedback_notice.css?version=' . MOV_VERSION ); +define( 'MOV_MAIN_CSS', MOV_URL . 'includes/css/mo-main.css?version=' . MOV_VERSION ); + +$target = realpath( __DIR__ . '/class-mootpsplclassloader.php' ); +if ( false !== $target && 0 === strpos( $target, realpath( MOV_DIR ) ) ) { + require $target; +} + +$idp_class_loader = new MoOTPSplClassLoader( 'OTP', realpath( __DIR__ . DIRECTORY_SEPARATOR . '..' ) ); +$idp_class_loader->register(); +$common_elements_path = realpath( __DIR__ . '/views/common-elements.php' ); +if ( false !== $common_elements_path && 0 === strpos( $common_elements_path, realpath( MOV_DIR ) ) ) { + require $common_elements_path; +} + +if ( file_exists( MOV_DIR . MoConstants::LICENCE_SERVICE_FILE ) ) { + new Mo_License_Library(); +} + + +/** + * Initializes handlers of forms. + * + * @return void + */ +function mo_initialize_forms() { + $forms_dir = MOV_DIR . 'handler/forms'; + $forms_path = realpath( $forms_dir ); + + if ( false === $forms_path || 0 !== strpos( $forms_path, realpath( MOV_DIR ) ) ) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $forms_path, RecursiveDirectoryIterator::SKIP_DOTS ), + RecursiveIteratorIterator::LEAVES_ONLY + ); + + $handler_list = FormList::instance(); + $is_block_checkout = mo_is_block_based_checkout(); + + foreach ( $iterator as $it ) { + $filename = $it->getFilename(); + $filename = sanitize_file_name( str_replace( 'class-', '', $filename ) ); + + if ( 'mowccheckoutnew.php' === $filename || 'woocommercecheckoutform.php' === $filename ) { + // Load only one WooCommerce checkout handler based on whether block-based checkout is enabled. + $selected_file = $is_block_checkout ? 'mowccheckoutnew.php' : 'woocommercecheckoutform.php'; + + // If the current file is not the selected handler, skip it to avoid duplicate registration. + if ( $filename !== $selected_file ) { + continue; + } + + $class_name = 'OTP\\Handler\\Forms\\' . str_replace( '.php', '', $selected_file ); + } else { + $class_name = 'OTP\\Handler\\Forms\\' . str_replace( '.php', '', $filename ); + } + + if ( class_exists( $class_name ) && method_exists( $class_name, 'instance' ) ) { + $form_handler = $class_name::instance(); + $handler_list->add( $form_handler->get_form_key(), $form_handler ); + } + } +} + +/** + * Returns if block checkout is enabled. + * + * This version does not rely on WooCommerce Blocks PHP classes being loaded. + * Instead, it inspects the WooCommerce Checkout page content for the + * `woocommerce/checkout` block, so it can be safely called early (e.g. in + * constructors) without depending on plugin load order. + * + * @return bool + */ +function mo_is_block_based_checkout() { + if ( ! function_exists( 'is_plugin_active' ) ) { + include_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + if ( ! is_plugin_active( 'woocommerce/woocommerce.php' ) ) { + return false; + } + + // Get the Checkout page ID directly from WooCommerce options. + $checkout_page_id = (int) get_option( 'woocommerce_checkout_page_id' ); + if ( $checkout_page_id <= 0 ) { + return false; + } + + $checkout_page = get_post( $checkout_page_id ); + if ( ! $checkout_page || empty( $checkout_page->post_content ) ) { + return false; + } + + // Prefer core has_block() if available (WP 5+). + if ( function_exists( 'has_block' ) ) { + return has_block( 'woocommerce/checkout', $checkout_page ); + } + + // Fallback: simple string search for the block name in post content. + return ( false !== strpos( $checkout_page->post_content, 'woocommerce/checkout' ) ); +} + +/** + * Returns admin post url. + * + * @return string + */ +function admin_post_url() { + return admin_url( 'admin-post.php' ); } + +/** + * Returns wp ajax url. + * + * @return string + */ +function wp_ajax_url() { + return admin_url( 'admin-ajax.php' ); } + +/** + * Escapes a string based on the type. + * + * @param string $string_value - string to be escaped. + * @param string $type - type of escaping to apply. + * @return string + */ +function mo_esc_string( $string_value, $type ) { + if ( 'attr' === $type ) { + return esc_attr( $string_value ); + } elseif ( 'url' === $type ) { + return esc_url( $string_value ); + } + + return esc_attr( $string_value ); +} + +/** + * Retrieves the value of the option from the wp_option table. + * + * @param string $string_value - option name to be retrieved. + * @param string $prefix - prefix of the option. + * @return mixed + */ +function get_mo_option( $string_value, $prefix = null ) { + $string_value = ( null === $prefix ? 'mo_customer_validation_' : $prefix ) . $string_value; + return apply_filters( 'get_mo_option', get_site_option( $string_value ) ); +} + +/** + * Updates the option set in the wp_option table. + * + * @param string $string_value - option name to be deleted. + * @param string $value - value of the option. + * @param string $prefix - prefix of the option. + */ +function update_mo_option( $string_value, $value, $prefix = null ) { + $string_value = ( null === $prefix ? 'mo_customer_validation_' : $prefix ) . $string_value; + update_site_option( $string_value, apply_filters( 'update_mo_option', $value, $string_value ) ); +} + +/** + * Deletes the option set in the wp_option table. + * + * @param string $string_value - option name to be deleted. + * @param string $prefix - prefix of the option. + */ +function delete_mo_option( $string_value, $prefix = null ) { + $string_value = ( null === $prefix ? 'mo_customer_validation_' : $prefix ) . $string_value; + delete_site_option( $string_value ); +} + +/** + * Returns the class name without namespace. + * + * @param object $obj - object of the class. + * @return string + */ +function get_mo_class( $obj ) { + $namespace_class = get_class( $obj ); + return substr( $namespace_class, strrpos( $namespace_class, '\\' ) + 1 ); +} + +/** + * To check if package.json file can be found through WP site URL or not. + * BuildScript.php updates the package.json file content in the below function instead of package.json to be used further in autoload.php + * example package.json string ["name"=>"miniorange-otp-verification","version"=>"5.5.2","type"=>"MiniOrangeGateway","testMode"=>false,"failMode"=>false,"hostname"=>"https:\/\/login.xecurify.com","dCustomerKey"=>"16555","dApiKey"=>"fFd2XcvTGDemZvbw1bcUesNJWEqKbbUq","sslVerify"=>true,"session"=>"AUTO"] + * + * @return string + */ +function initialize_package_json() { + $package = wp_json_encode( + array( + 'name' => 'miniorange-otp-verification', + 'version' => '5.5.2', + 'type' => 'MiniOrangeGateway', + 'testmode' => false, + 'failmode' => false, + 'hostname' => 'https://login.xecurify.com', + 'portal' => 'https://portal.miniorange.com', + 'dcustomerkey' => '16555', + 'dapikey' => 'fFd2XcvTGDemZvbw1bcUesNJWEqKbbUq', + 'sslverify' => true, + 'session' => 'AUTO', + 'typeplan' => 'wp_otp_verification_basic_plan', + 'licensename' => 'WP_OTP_VERIFICATION_PLUGIN', + ) + ); + return $package; +} @@ -1,489 +1,491 @@ -<?php -/** - * Main File MoInit - * - * @package miniorange-otp-verification - */ - -namespace OTP; - -if ( ! defined( 'ABSPATH' ) ) { - exit; -} - -use OTP\Handler\EmailVerificationLogic; -use OTP\Handler\FormActionHandler; -use OTP\Handler\MoActionHandlerHandler; -use OTP\Handler\MoRegistrationHandler; -use OTP\Handler\PhoneVerificationLogic; -use OTP\Helper\CountryList; -use OTP\Helper\GatewayFunctions; -use OTP\Helper\MenuItems; -use OTP\Helper\MoConstants; -use OTP\Helper\MoDisplayMessages; -use OTP\Helper\MoMessages; -use OTP\Helper\MoUtility; -use OTP\Helper\MOVisualTour; -use OTP\Helper\Templates\DefaultPopup; -use OTP\Helper\Templates\ErrorPopup; -use OTP\Helper\Templates\ExternalPopup; -use OTP\Helper\Templates\UserChoicePopup; -use OTP\Objects\PluginPageDetails; -use OTP\Objects\TabDetails; -use OTP\Objects\Tabs; -use OTP\Traits\Instance; -use OTP\Helper\MocURLCall; -use OTP\Objects\BaseMessages; -use OTP\Helper\MoVersionUpdate; -use OTP\Helper\MoAlphaNumeric; -use OTP\Helper\MoSMSBackupGateway; -use OTP\Helper\MoGloballyBannedPhone; -use OTP\Helper\MoWhatsApp; -use OTP\Helper\MoMasterCode; -use OTP\Helper\PopupTemplateChange; -use OTP\Helper\MoReporting; -use OTP\Helper\TransactionCost; -use OTP\Helper\MoAutofill; -use OTP\Helper\MoPHPSessions; - -if ( ! class_exists( 'MoInit' ) ) { - /** - * Final class that runs base functionalities of the plugin. - * It initializes some of the common helper and handler for the plugin - * classes. - */ - final class MoInit { - - use Instance; - - /** - * Constructor - */ - private function __construct() { - MoPHPSessions::bootstrap(); - mo_initialize_forms(); - $this->initialize_hooks(); - $this->initialize_globals(); - $this->initialize_helpers(); - $this->initialize_handlers(); - $this->register_addons(); - } - - /** - * Initialize all the main hooks needed for the plugin - */ - private function initialize_hooks() { - add_action( 'plugins_loaded', array( $this, 'otp_load_textdomain' ), 1 ); - add_action( 'admin_menu', array( $this, 'miniorange_customer_validation_menu' ) ); - add_action( 'admin_enqueue_scripts', array( $this, 'mo_registration_plugin_settings_style' ) ); - add_action( 'admin_enqueue_scripts', array( $this, 'mo_registration_plugin_settings_script' ) ); - add_action( 'wp_enqueue_scripts', array( $this, 'mo_registration_plugin_frontend_scripts' ), 99 ); - add_action( 'login_enqueue_scripts', array( $this, 'mo_registration_plugin_frontend_scripts' ), 99 ); - add_action( 'mo_registration_show_message', array( $this, 'mo_show_otp_message' ), 1, 2 ); - add_action( 'hourly_sync', array( $this, 'hourly_sync' ) ); - add_action( 'admin_footer', array( $this, 'feedback_request' ) ); - add_filter( 'wp_mail_from_name', array( $this, 'custom_wp_mail_from_name' ) ); - add_filter( 'plugin_row_meta', array( $this, 'mo_meta_links' ), 10, 2 ); - add_action( 'wp_enqueue_scripts', array( $this, 'load_jquery_on_forms' ) ); - add_action( 'plugin_action_links_' . MOV_PLUGIN_NAME, array( $this, 'plugin_action_links' ), 10, 1 ); - } - - /** - * Function to check if jQuery library is included, if not present then insert it. - * This was added to avoid conflicts with other scripts in WordPress all the while - * making sure our plugin is working as intended. - */ - public function load_jquery_on_forms() { - if ( ! wp_script_is( 'jquery', 'enqueued' ) ) { - wp_enqueue_script( 'jquery' ); - } - } - - /** - * Initialize all the helper classes with proper file validation - */ - private function initialize_helpers() { - MoMessages::instance(); - MOVisualTour::instance(); - TransactionCost::instance(); - - // Initialize helper singletons using fully-qualified class names. - $helper_classes = array( - MoVersionUpdate::class, - MoAlphaNumeric::class, - MoSMSBackupGateway::class, - MoGloballyBannedPhone::class, - MoWhatsApp::class, - MoMasterCode::class, - MoReporting::class, - PopupTemplateChange::class, - MoAutofill::class, - ); - - foreach ( $helper_classes as $helper_class ) { - try { - // Derive the expected helper file path (e.g. helper/class-moreporting.php). - $short_name = substr( $helper_class, strrpos( $helper_class, '\\' ) + 1 ); - $file_name = 'class-' . strtolower( $short_name ) . '.php'; - $helper_dir = MOV_DIR . 'helper' . DIRECTORY_SEPARATOR; - $helper_file_path = $helper_dir . $file_name; - - $real_helper_file = realpath( $helper_file_path ); - $real_helper_dir = realpath( $helper_dir ); - - // Only load the file if it exists inside the expected helper directory. - if ( $real_helper_file && $real_helper_dir && 0 === strpos( $real_helper_file, $real_helper_dir ) && file_exists( $real_helper_file ) ) { - require_once $real_helper_file; - } - - if ( class_exists( $helper_class, false ) && method_exists( $helper_class, 'instance' ) ) { - $helper_class::instance(); - } - } catch ( \Exception $e ) { - continue; - } catch ( \Error $e ) { - continue; - } - } - } - - /** - * Initialize all the Template Handlers - */ - private function initialize_handlers() { - FormActionHandler::instance(); - MoActionHandlerHandler::instance(); - DefaultPopup::instance(); - ErrorPopup::instance(); - ExternalPopup::instance(); - UserChoicePopup::instance(); - MoRegistrationHandler::instance(); - } - - /** - * Initialize all the global variables. - */ - private function initialize_globals() { - global $phone_logic, $email_logic; - $phone_logic = PhoneVerificationLogic::instance(); - $email_logic = EmailVerificationLogic::instance(); - } - - /** - * This function hooks into the admin_menu WordPress hook to generate - * WordPress menu items. You define all the options and links you want - * to show to the admin in the WordPress sidebar. - */ - public function miniorange_customer_validation_menu() { - MenuItems::instance(); - } - - - /** - * The main callback function for each of the menu links. This function - * is called when user visits any one of the menu URLs. - */ - public function mo_customer_validation_options() { - if ( ! current_user_can( 'manage_options' ) ) { - return; - } - $controller_file = realpath( MOV_DIR . 'controllers/main-controller.php' ); - $base_dir = realpath( MOV_DIR . 'controllers/' ); - if ( MoUtility::mo_require_file( $controller_file, $base_dir ) ) { - require $controller_file; - } else { - return; - } - } - - /** - * This function checks the current page to load the main scripts and styles on admin dashboard only - */ - public function check_current_page() { - - // Only load scripts on OTP plugin pages. - $current_screen = get_current_screen(); - if ( ! $current_screen ) { - return false; - } - $otp_plugin_pages = array( - 'toplevel_page_mosettings', - 'otp-verification_page_monotifications', - 'otp-verification_page_otpsettings', - 'otp-verification_page_mogateway', - 'otp-verification_page_moreporting', - 'otp-verification_page_mowhatsapp', - 'otp-verification_page_addon', - 'otp-verification_page_otpaccount', - 'otp-verification_page_mootppricing', - ); - - // Also check by page parameter for additional safety. - $page = MoUtility::get_current_page_parameter_value( 'page', '' ); - $otp_page_slugs = array( - 'mosettings', - 'monotifications', - 'otpsettings', - 'mogateway', - 'moreporting', - 'mowhatsapp', - 'addon', - 'otpaccount', - 'mootppricing', - ); - - // Only load scripts if we're on an OTP plugin page. - if ( ! in_array( $current_screen->id, $otp_plugin_pages, true ) && ! in_array( $page, $otp_page_slugs, true ) ) { - return true; - } - } - - /** - * This function is called to append our CSS file - * in the backend and frontend. Uses the admin_enqueue_scripts - * and enqueue_scripts WordPress hook. - */ - public function mo_registration_plugin_settings_style() { - // Load feedback styles on all admin pages since feedback form appears on all pages. - wp_enqueue_style( 'mo_customer_validation_feedback_style', MOV_CSS, array(), MOV_VERSION ); - - if ( $this->check_current_page() ) { - return; - } - wp_enqueue_style( 'mo_customer_validation_admin_settings_style', MOV_CSS_URL, array(), MOV_VERSION ); - wp_enqueue_style( 'mo_customer_validation_form_main_css', MOV_FORM_CSS, array(), MOV_VERSION ); - wp_enqueue_style( 'mo_customer_validation_inttelinput_style', MO_INTTELINPUT_CSS, array(), MOV_VERSION ); - wp_enqueue_style( 'mo_main_style', MOV_MAIN_CSS, array(), MOV_VERSION ); - } - - - /** - * This function is called to append our CSS file - * in the backend and frontend. Uses the admin_enqueue_scripts - * and enqueue_scripts WordPress hook. - */ - public function mo_registration_plugin_settings_script() { - // Load feedback script on all admin pages since feedback form appears on all pages. - wp_enqueue_script( 'mo_customer_validation_feedback_script', MOV_FEEDBACK_JS, array( 'jquery' ), MOV_VERSION, false ); - - if ( $this->check_current_page() ) { - return; - } - $country_val = array(); - $whatsapp_enabled = get_mo_option( 'mo_whatsapp_enable' ); - $request_uri = remove_query_arg( array( 'mosettings', 'form', 'subpage' ), isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '' ); - $whatsapp_tab_url = add_query_arg( array( 'page' => 'mowhatsapp' ), $request_uri ); - $whatsapp_file = file_exists( MOV_DIR . 'helper' . DIRECTORY_SEPARATOR . 'class-mowhatsapp.php' ); - - wp_enqueue_script( 'mo_customer_validation_admin_settings_script', MOV_JS_URL, array( 'jquery' ), MOV_VERSION, false ); - wp_localize_script( - 'mo_customer_validation_admin_settings_script', - 'moadminsettings', - array( - 'iswhatsappenable' => $whatsapp_enabled, - 'whatsapp_tab' => $whatsapp_tab_url, - 'whatsapp_file' => $whatsapp_file, - 'whatsapp_enabled_text' => esc_html__( 'OTP Over WhatsApp Enabled', 'miniorange-otp-verification' ), - 'whatsapp_disabled_text' => esc_html__( 'Enable OTP Over WhatsApp?', 'miniorange-otp-verification' ), - 'form_is_not_found' => MoMessages::showMessage( MoMessages::FORM_IS_NOT_FOUND ), - 'ajaxUrl' => admin_url( 'admin-ajax.php' ), - 'security' => wp_create_nonce( 'mo_admin_actions' ), - 'mo_twilio_setupguide' => MoConstants::MO_TWILIO_SETUP_GUIDE, - 'mo_gateway_setupguide' => MoConstants::MO_GATEWAY_SETUP_GUIDE, - - ) - ); - wp_enqueue_script( 'mo_customer_validation_form_validation_script', VALIDATION_JS_URL, array( 'jquery' ), MOV_VERSION, false ); - wp_register_script( 'mo_customer_validation_inttelinput_script', MO_INTTELINPUT_JS, array( 'jquery' ), MOV_VERSION, false ); - $countriesavail = CountryList::get_countrycode_list(); - $countriesavail = apply_filters( 'selected_countries', $countriesavail ); - foreach ( $countriesavail as $key => $value ) { - array_push( $country_val, $value ); - } - wp_localize_script( - 'mo_customer_validation_inttelinput_script', - 'moselecteddropdown', - array( - 'selecteddropdown' => $country_val, - ) - ); - wp_enqueue_script( 'mo_customer_validation_inttelinput_script' ); - } - - - /** - * This function is called to append certain javascripts - * to the frontend. Mostly used for the appending a country - * code dropdown to the phone number field. - */ - public function mo_registration_plugin_frontend_scripts() { - wp_enqueue_style( 'mo_customer_validation_form_main_css', MOV_FORM_CSS, array(), MOV_VERSION ); - $country_val = array(); - if ( ! get_mo_option( 'show_dropdown_on_form' ) ) { - return; - } - $selector = apply_filters( 'mo_phone_dropdown_selector', array() ); - if ( MoUtility::is_blank( $selector ) ) { - return; - } - $selector = array_unique( $selector ); - $countriesavail = CountryList::get_countrycode_list(); - $countriesavail = apply_filters( 'selected_countries', $countriesavail ); - foreach ( $countriesavail as $key => $value ) { - array_push( $country_val, $value ); - } - $default_country = CountryList::get_default_country_iso_code(); - $get_ip_country = apply_filters( 'mo_get_default_country', $default_country ); - wp_register_script( 'mo_customer_validation_inttelinput_script', MO_INTTELINPUT_JS, array( 'jquery' ), MOV_VERSION, false ); - wp_localize_script( - 'mo_customer_validation_inttelinput_script', - 'moselecteddropdown', - array( - 'selecteddropdown' => $country_val, - - ) - ); - wp_enqueue_script( 'mo_customer_validation_inttelinput_script' ); - - wp_enqueue_style( 'mo_customer_validation_inttelinput_style', MO_INTTELINPUT_CSS, array(), MOV_VERSION ); - wp_register_script( 'mo_customer_validation_dropdown_script', MO_DROPDOWN_JS, array( 'jquery' ), MOV_VERSION, true ); - wp_localize_script( - 'mo_customer_validation_dropdown_script', - 'modropdownvars', - array( - 'selector' => wp_json_encode( $selector ), - 'defaultCountry' => $get_ip_country, - 'onlyCountries' => CountryList::get_only_country_list(), - ) - ); - wp_enqueue_script( 'mo_customer_validation_dropdown_script' ); - } - - - /** - * This function runs when mo_registration_show_message hook - * is initiated. The hook runs to show a plugin generated - * message to the user in the admin dashboard. - * - * @param string $content refers to the message content. - * @param string $type refers to the type of message. - */ - public function mo_show_otp_message( $content, $type ) { - new MoDisplayMessages( $content, $type ); - } - - - - /** - * Function tells where to look for translations. - * <b>PLEASE NOTE:</b> Dont be clever and try to replace the Text domain 'miniorange-otp-verification' - * with a constant value. Its kept as string for a reason. Its so that other automated - * tools can read it and use it for automatic translation. - */ - public function otp_load_textdomain() { - load_plugin_textdomain( 'miniorange-otp-verification', false, dirname( plugin_basename( __FILE__ ) ) . '/lang/' ); - } - - /** - * Function initializes all the AddOns associated with the plugin. - * - * We can use reflection and automate the instantiation process - * but that will be a little costly and would affect performance - * hence decided not to. - */ - private function register_addons() { - - $gateway = GatewayFunctions::instance(); - $gateway->register_addons(); - } - - /** - * Function hooks into the admin_footer hook to append the feedback form in the - * footer section of the page. - */ - public function feedback_request() { - if ( ! current_user_can( 'manage_options' ) ) { - return; - } - $feedback_file = realpath( MOV_DIR . 'controllers/feedback.php' ); - $base_dir = realpath( MOV_DIR . 'controllers/' ); - if ( MoUtility::mo_require_file( $feedback_file, $base_dir ) ) { - require $feedback_file; - } else { - return; - } - } - - - /** - * Function hooks into the plugin_row_meta link to add custom - * links to the plugin's page. - * - * @param object $meta_fields . - * @param object $file . - * @return array - */ - public function mo_meta_links( $meta_fields, $file ) { - if ( MOV_PLUGIN_NAME === $file ) { - $meta_fields[] = "<span class='dashicons dashicons-sticky'></span> - <a href='" . MoConstants::FAQ_URL . "' target='_blank'>" . esc_html__( 'FAQs', 'miniorange-otp-verification' ) . '</a>'; - } - return $meta_fields; - } - - - /** - * Add action links to the plugin list page for easy navigation - * after plugin activation. - * - * @param string $links . - * @return array - */ - public function plugin_action_links( $links ) { - - $tab_details = TabDetails::instance(); - - $form_settings_tab = $tab_details->tab_details[ Tabs::FORMS ]; - if ( ! function_exists( 'is_plugin_active' ) ) { - include_once ABSPATH . 'wp-admin/includes/plugin.php'; - } - if ( is_plugin_active( MOV_PLUGIN_NAME ) ) { - $links = array_merge( - array( - '<a href="' . esc_url( admin_url( 'admin.php?page=' . $form_settings_tab->menu_slug ) ) . '">' . - esc_html__( 'Settings', 'miniorange-otp-verification' ) - . '</a>', - ), - $links - ); - } - return $links; - } - - /** - * Daily sync to do a license check and update the email and - * SMS Transaction. - * - * @note - this might say hourlySync but it's actually a daily sync - */ - public function hourly_sync() { - $gateway = GatewayFunctions::instance(); - $gateway->hourly_sync(); - } - - /** - * Change the from name going out in the email - * via WP_MAIL of WordPress. - * - * @param String $original_email_from The Original From Email Address passed by the hook. - * @return String From Email Address for the email going out - */ - public function custom_wp_mail_from_name( $original_email_from ) { - if ( is_admin() && ! current_user_can( 'manage_options' ) ) { - return $original_email_from; - } - $gateway = GatewayFunctions::instance(); - return $gateway->custom_wp_mail_from_name( $original_email_from ); - } - } -} +<?php +/** + * Main File MoInit + * + * @package miniorange-otp-verification + */ + +namespace OTP; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +use OTP\API\MoAbilitiesApi; +use OTP\Handler\EmailVerificationLogic; +use OTP\Handler\FormActionHandler; +use OTP\Handler\MoActionHandlerHandler; +use OTP\Handler\MoRegistrationHandler; +use OTP\Handler\PhoneVerificationLogic; +use OTP\Helper\CountryList; +use OTP\Helper\GatewayFunctions; +use OTP\Helper\MenuItems; +use OTP\Helper\MoConstants; +use OTP\Helper\MoDisplayMessages; +use OTP\Helper\MoMessages; +use OTP\Helper\MoUtility; +use OTP\Helper\MOVisualTour; +use OTP\Helper\Templates\DefaultPopup; +use OTP\Helper\Templates\ErrorPopup; +use OTP\Helper\Templates\ExternalPopup; +use OTP\Helper\Templates\UserChoicePopup; +use OTP\Objects\PluginPageDetails; +use OTP\Objects\TabDetails; +use OTP\Objects\Tabs; +use OTP\Traits\Instance; +use OTP\Helper\MocURLCall; +use OTP\Objects\BaseMessages; +use OTP\Helper\MoVersionUpdate; +use OTP\Helper\MoAlphaNumeric; +use OTP\Helper\MoSMSBackupGateway; +use OTP\Helper\MoGloballyBannedPhone; +use OTP\Helper\MoWhatsApp; +use OTP\Helper\MoMasterCode; +use OTP\Helper\PopupTemplateChange; +use OTP\Helper\MoReporting; +use OTP\Helper\TransactionCost; +use OTP\Helper\MoAutofill; +use OTP\Helper\MoPHPSessions; + +if ( ! class_exists( 'MoInit' ) ) { + /** + * Final class that runs base functionalities of the plugin. + * It initializes some of the common helper and handler for the plugin + * classes. + */ + final class MoInit { + + use Instance; + + /** + * Constructor + */ + private function __construct() { + MoPHPSessions::bootstrap(); + mo_initialize_forms(); + $this->initialize_hooks(); + $this->initialize_globals(); + $this->initialize_helpers(); + $this->initialize_handlers(); + $this->register_addons(); + } + + /** + * Initialize all the main hooks needed for the plugin + */ + private function initialize_hooks() { + add_action( 'plugins_loaded', array( $this, 'otp_load_textdomain' ), 1 ); + add_action( 'admin_menu', array( $this, 'miniorange_customer_validation_menu' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'mo_registration_plugin_settings_style' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'mo_registration_plugin_settings_script' ) ); + add_action( 'wp_enqueue_scripts', array( $this, 'mo_registration_plugin_frontend_scripts' ), 99 ); + add_action( 'login_enqueue_scripts', array( $this, 'mo_registration_plugin_frontend_scripts' ), 99 ); + add_action( 'mo_registration_show_message', array( $this, 'mo_show_otp_message' ), 1, 2 ); + add_action( 'hourly_sync', array( $this, 'hourly_sync' ) ); + add_action( 'admin_footer', array( $this, 'feedback_request' ) ); + add_filter( 'wp_mail_from_name', array( $this, 'custom_wp_mail_from_name' ) ); + add_filter( 'plugin_row_meta', array( $this, 'mo_meta_links' ), 10, 2 ); + add_action( 'wp_enqueue_scripts', array( $this, 'load_jquery_on_forms' ) ); + add_action( 'plugin_action_links_' . MOV_PLUGIN_NAME, array( $this, 'plugin_action_links' ), 10, 1 ); + } + + /** + * Function to check if jQuery library is included, if not present then insert it. + * This was added to avoid conflicts with other scripts in WordPress all the while + * making sure our plugin is working as intended. + */ + public function load_jquery_on_forms() { + if ( ! wp_script_is( 'jquery', 'enqueued' ) ) { + wp_enqueue_script( 'jquery' ); + } + } + + /** + * Initialize all the helper classes with proper file validation + */ + private function initialize_helpers() { + MoMessages::instance(); + MOVisualTour::instance(); + TransactionCost::instance(); + + // Initialize helper singletons using fully-qualified class names. + $helper_classes = array( + MoVersionUpdate::class, + MoAlphaNumeric::class, + MoSMSBackupGateway::class, + MoGloballyBannedPhone::class, + MoWhatsApp::class, + MoMasterCode::class, + MoReporting::class, + PopupTemplateChange::class, + MoAutofill::class, + ); + + foreach ( $helper_classes as $helper_class ) { + try { + // Derive the expected helper file path (e.g. helper/class-moreporting.php). + $short_name = substr( $helper_class, strrpos( $helper_class, '\\' ) + 1 ); + $file_name = 'class-' . strtolower( $short_name ) . '.php'; + $helper_dir = MOV_DIR . 'helper' . DIRECTORY_SEPARATOR; + $helper_file_path = $helper_dir . $file_name; + + $real_helper_file = realpath( $helper_file_path ); + $real_helper_dir = realpath( $helper_dir ); + + // Only load the file if it exists inside the expected helper directory. + if ( $real_helper_file && $real_helper_dir && 0 === strpos( $real_helper_file, $real_helper_dir ) && file_exists( $real_helper_file ) ) { + require_once $real_helper_file; + } + + if ( class_exists( $helper_class, false ) && method_exists( $helper_class, 'instance' ) ) { + $helper_class::instance(); + } + } catch ( \Exception $e ) { + continue; + } catch ( \Error $e ) { + continue; + } + } + } + + /** + * Initialize all the Template Handlers + */ + private function initialize_handlers() { + FormActionHandler::instance(); + MoActionHandlerHandler::instance(); + DefaultPopup::instance(); + ErrorPopup::instance(); + ExternalPopup::instance(); + UserChoicePopup::instance(); + MoRegistrationHandler::instance(); + MoAbilitiesApi::instance(); + } + + /** + * Initialize all the global variables. + */ + private function initialize_globals() { + global $phone_logic, $email_logic; + $phone_logic = PhoneVerificationLogic::instance(); + $email_logic = EmailVerificationLogic::instance(); + } + + /** + * This function hooks into the admin_menu WordPress hook to generate + * WordPress menu items. You define all the options and links you want + * to show to the admin in the WordPress sidebar. + */ + public function miniorange_customer_validation_menu() { + MenuItems::instance(); + } + + + /** + * The main callback function for each of the menu links. This function + * is called when user visits any one of the menu URLs. + */ + public function mo_customer_validation_options() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + $controller_file = realpath( MOV_DIR . 'controllers/main-controller.php' ); + $base_dir = realpath( MOV_DIR . 'controllers/' ); + if ( MoUtility::mo_require_file( $controller_file, $base_dir ) ) { + require $controller_file; + } else { + return; + } + } + + /** + * This function checks the current page to load the main scripts and styles on admin dashboard only + */ + public function check_current_page() { + + // Only load scripts on OTP plugin pages. + $current_screen = get_current_screen(); + if ( ! $current_screen ) { + return false; + } + $otp_plugin_pages = array( + 'toplevel_page_mosettings', + 'otp-verification_page_monotifications', + 'otp-verification_page_otpsettings', + 'otp-verification_page_mogateway', + 'otp-verification_page_moreporting', + 'otp-verification_page_mowhatsapp', + 'otp-verification_page_addon', + 'otp-verification_page_otpaccount', + 'otp-verification_page_mootppricing', + ); + + // Also check by page parameter for additional safety. + $page = MoUtility::get_current_page_parameter_value( 'page', '' ); + $otp_page_slugs = array( + 'mosettings', + 'monotifications', + 'otpsettings', + 'mogateway', + 'moreporting', + 'mowhatsapp', + 'addon', + 'otpaccount', + 'mootppricing', + ); + + // Only load scripts if we're on an OTP plugin page. + if ( ! in_array( $current_screen->id, $otp_plugin_pages, true ) && ! in_array( $page, $otp_page_slugs, true ) ) { + return true; + } + } + + /** + * This function is called to append our CSS file + * in the backend and frontend. Uses the admin_enqueue_scripts + * and enqueue_scripts WordPress hook. + */ + public function mo_registration_plugin_settings_style() { + // Load feedback styles on all admin pages since feedback form appears on all pages. + wp_enqueue_style( 'mo_customer_validation_feedback_style', MOV_CSS, array(), MOV_VERSION ); + + if ( $this->check_current_page() ) { + return; + } + wp_enqueue_style( 'mo_customer_validation_admin_settings_style', MOV_CSS_URL, array(), MOV_VERSION ); + wp_enqueue_style( 'mo_customer_validation_form_main_css', MOV_FORM_CSS, array(), MOV_VERSION ); + wp_enqueue_style( 'mo_customer_validation_inttelinput_style', MO_INTTELINPUT_CSS, array(), MOV_VERSION ); + wp_enqueue_style( 'mo_main_style', MOV_MAIN_CSS, array(), MOV_VERSION ); + } + + + /** + * This function is called to append our CSS file + * in the backend and frontend. Uses the admin_enqueue_scripts + * and enqueue_scripts WordPress hook. + */ + public function mo_registration_plugin_settings_script() { + // Load feedback script on all admin pages since feedback form appears on all pages. + wp_enqueue_script( 'mo_customer_validation_feedback_script', MOV_FEEDBACK_JS, array( 'jquery' ), MOV_VERSION, false ); + + if ( $this->check_current_page() ) { + return; + } + $country_val = array(); + $whatsapp_enabled = get_mo_option( 'mo_whatsapp_enable' ); + $request_uri = remove_query_arg( array( 'mosettings', 'form', 'subpage' ), isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '' ); + $whatsapp_tab_url = add_query_arg( array( 'page' => 'mowhatsapp' ), $request_uri ); + $whatsapp_file = file_exists( MOV_DIR . 'helper' . DIRECTORY_SEPARATOR . 'class-mowhatsapp.php' ); + + wp_enqueue_script( 'mo_customer_validation_admin_settings_script', MOV_JS_URL, array( 'jquery' ), MOV_VERSION, false ); + wp_localize_script( + 'mo_customer_validation_admin_settings_script', + 'moadminsettings', + array( + 'iswhatsappenable' => $whatsapp_enabled, + 'whatsapp_tab' => $whatsapp_tab_url, + 'whatsapp_file' => $whatsapp_file, + 'whatsapp_enabled_text' => esc_html__( 'OTP Over WhatsApp Enabled', 'miniorange-otp-verification' ), + 'whatsapp_disabled_text' => esc_html__( 'Enable OTP Over WhatsApp?', 'miniorange-otp-verification' ), + 'form_is_not_found' => MoMessages::showMessage( MoMessages::FORM_IS_NOT_FOUND ), + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'security' => wp_create_nonce( 'mo_admin_actions' ), + 'mo_twilio_setupguide' => MoConstants::MO_TWILIO_SETUP_GUIDE, + 'mo_gateway_setupguide' => MoConstants::MO_GATEWAY_SETUP_GUIDE, + + ) + ); + wp_enqueue_script( 'mo_customer_validation_form_validation_script', VALIDATION_JS_URL, array( 'jquery' ), MOV_VERSION, false ); + wp_register_script( 'mo_customer_validation_inttelinput_script', MO_INTTELINPUT_JS, array( 'jquery' ), MOV_VERSION, false ); + $countriesavail = CountryList::get_countrycode_list(); + $countriesavail = apply_filters( 'selected_countries', $countriesavail ); + foreach ( $countriesavail as $key => $value ) { + array_push( $country_val, $value ); + } + wp_localize_script( + 'mo_customer_validation_inttelinput_script', + 'moselecteddropdown', + array( + 'selecteddropdown' => $country_val, + ) + ); + wp_enqueue_script( 'mo_customer_validation_inttelinput_script' ); + } + + + /** + * This function is called to append certain javascripts + * to the frontend. Mostly used for the appending a country + * code dropdown to the phone number field. + */ + public function mo_registration_plugin_frontend_scripts() { + wp_enqueue_style( 'mo_customer_validation_form_main_css', MOV_FORM_CSS, array(), MOV_VERSION ); + $country_val = array(); + if ( ! get_mo_option( 'show_dropdown_on_form' ) ) { + return; + } + $selector = apply_filters( 'mo_phone_dropdown_selector', array() ); + if ( MoUtility::is_blank( $selector ) ) { + return; + } + $selector = array_unique( $selector ); + $countriesavail = CountryList::get_countrycode_list(); + $countriesavail = apply_filters( 'selected_countries', $countriesavail ); + foreach ( $countriesavail as $key => $value ) { + array_push( $country_val, $value ); + } + $default_country = CountryList::get_default_country_iso_code(); + $get_ip_country = apply_filters( 'mo_get_default_country', $default_country ); + wp_register_script( 'mo_customer_validation_inttelinput_script', MO_INTTELINPUT_JS, array( 'jquery' ), MOV_VERSION, false ); + wp_localize_script( + 'mo_customer_validation_inttelinput_script', + 'moselecteddropdown', + array( + 'selecteddropdown' => $country_val, + + ) + ); + wp_enqueue_script( 'mo_customer_validation_inttelinput_script' ); + + wp_enqueue_style( 'mo_customer_validation_inttelinput_style', MO_INTTELINPUT_CSS, array(), MOV_VERSION ); + wp_register_script( 'mo_customer_validation_dropdown_script', MO_DROPDOWN_JS, array( 'jquery' ), MOV_VERSION, true ); + wp_localize_script( + 'mo_customer_validation_dropdown_script', + 'modropdownvars', + array( + 'selector' => wp_json_encode( $selector ), + 'defaultCountry' => $get_ip_country, + 'onlyCountries' => CountryList::get_only_country_list(), + ) + ); + wp_enqueue_script( 'mo_customer_validation_dropdown_script' ); + } + + + /** + * This function runs when mo_registration_show_message hook + * is initiated. The hook runs to show a plugin generated + * message to the user in the admin dashboard. + * + * @param string $content refers to the message content. + * @param string $type refers to the type of message. + */ + public function mo_show_otp_message( $content, $type ) { + new MoDisplayMessages( $content, $type ); + } + + + + /** + * Function tells where to look for translations. + * <b>PLEASE NOTE:</b> Dont be clever and try to replace the Text domain 'miniorange-otp-verification' + * with a constant value. Its kept as string for a reason. Its so that other automated + * tools can read it and use it for automatic translation. + */ + public function otp_load_textdomain() { + load_plugin_textdomain( 'miniorange-otp-verification', false, dirname( plugin_basename( __FILE__ ) ) . '/lang/' ); + } + + /** + * Function initializes all the AddOns associated with the plugin. + * + * We can use reflection and automate the instantiation process + * but that will be a little costly and would affect performance + * hence decided not to. + */ + private function register_addons() { + + $gateway = GatewayFunctions::instance(); + $gateway->register_addons(); + } + + /** + * Function hooks into the admin_footer hook to append the feedback form in the + * footer section of the page. + */ + public function feedback_request() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + $feedback_file = realpath( MOV_DIR . 'controllers/feedback.php' ); + $base_dir = realpath( MOV_DIR . 'controllers/' ); + if ( MoUtility::mo_require_file( $feedback_file, $base_dir ) ) { + require $feedback_file; + } else { + return; + } + } + + + /** + * Function hooks into the plugin_row_meta link to add custom + * links to the plugin's page. + * + * @param object $meta_fields . + * @param object $file . + * @return array + */ + public function mo_meta_links( $meta_fields, $file ) { + if ( MOV_PLUGIN_NAME === $file ) { + $meta_fields[] = "<span class='dashicons dashicons-sticky'></span> + <a href='" . MoConstants::FAQ_URL . "' target='_blank'>" . esc_html__( 'FAQs', 'miniorange-otp-verification' ) . '</a>'; + } + return $meta_fields; + } + + + /** + * Add action links to the plugin list page for easy navigation + * after plugin activation. + * + * @param string $links . + * @return array + */ + public function plugin_action_links( $links ) { + + $tab_details = TabDetails::instance(); + + $form_settings_tab = $tab_details->tab_details[ Tabs::FORMS ]; + if ( ! function_exists( 'is_plugin_active' ) ) { + include_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + if ( is_plugin_active( MOV_PLUGIN_NAME ) ) { + $links = array_merge( + array( + '<a href="' . esc_url( admin_url( 'admin.php?page=' . $form_settings_tab->menu_slug ) ) . '">' . + esc_html__( 'Settings', 'miniorange-otp-verification' ) + . '</a>', + ), + $links + ); + } + return $links; + } + + /** + * Daily sync to do a license check and update the email and + * SMS Transaction. + * + * @note - this might say hourlySync but it's actually a daily sync + */ + public function hourly_sync() { + $gateway = GatewayFunctions::instance(); + $gateway->hourly_sync(); + } + + /** + * Change the from name going out in the email + * via WP_MAIL of WordPress. + * + * @param String $original_email_from The Original From Email Address passed by the hook. + * @return String From Email Address for the email going out + */ + public function custom_wp_mail_from_name( $original_email_from ) { + if ( is_admin() && ! current_user_can( 'manage_options' ) ) { + return $original_email_from; + } + $gateway = GatewayFunctions::instance(); + return $gateway->custom_wp_mail_from_name( $original_email_from ); + } + } +} @@ -101,7 +101,11 @@ * @param String $form - form values. */ public function check_form_submit( $insert_data, $data, $form ) { - $form_id = null === $form['form_id'] ? $form['attributes']['id'] : $form['form_id']; + if ( is_array( $form ) ) { + $form_id = isset( $form['form_id'] ) ? $form['form_id'] : ( $form['id'] ?? null ); + } else { + $form_id = $form->id ?? null; + } if ( ! array_key_exists( $form_id, $this->form_details ) ) { return; } @@ -190,7 +194,7 @@ } $post_data = MoUtility::mo_sanitize_array( $_POST ); MoUtility::initialize_transaction( $this->form_session_var ); - if ( $post_data['otpType'] === $this->type_phone_tag ) { + if ( $post_data['otptype'] === $this->type_phone_tag ) { $this->process_phone_and_send_otp( $post_data ); } else { $this->process_email_and_send_otp( $post_data ); @@ -220,9 +220,9 @@ private function check_integrity_and_validate_otp( $data ) { $this->check_integrity( $data ); - $this->validate_challenge( sanitize_text_field( $data['otpType'] ), null, sanitize_text_field( $data['otp_token'] ) ); + $this->validate_challenge( sanitize_text_field( $data['otptype'] ), null, sanitize_text_field( $data['otp_token'] ) ); - if ( SessionUtils::is_status_match( $this->form_session_var, self::VALIDATED, sanitize_text_field( $data['otpType'] ) ) ) { + if ( SessionUtils::is_status_match( $this->form_session_var, self::VALIDATED, sanitize_text_field( $data['otptype'] ) ) ) { wp_send_json( MoUtility::create_json( MoConstants::SUCCESS_JSON_TYPE, @@ -249,7 +249,7 @@ * @param array $data - this is the get / post data from the ajax call containing email or phone. */ private function check_integrity( $data ) { - if ( 'phone' === $data['otpType'] ) { + if ( 'phone' === $data['otptype'] ) { $phone = MoUtility::process_phone_number( sanitize_text_field( $data['user_phone'] ) ); if ( ! SessionUtils::is_phone_verified_match( $this->form_session_var, $phone ) ) { wp_send_json( @@ -364,8 +364,22 @@ if ( ! isset( $_POST['security'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['security'] ) ), 'form_nonce' ) ) { return; } - $user = MoUtility::sanitize_check( 'username_b', $_POST ); - $user = $this->get_user( trim( $user ) ); + + $otp_ver_type = $this->get_verification_type(); + if ( ! SessionUtils::is_otp_initialized( $this->form_session_var ) + || ! SessionUtils::is_status_match( $this->form_session_var, self::VALIDATED, $otp_ver_type ) ) { + return; + } + + $posted_username = MoUtility::sanitize_check( 'username_b', $_POST ); + $session_username = SessionUtils::get_user_submitted( $this->form_session_var ); + if ( MoUtility::is_blank( $session_username ) || $session_username !== trim( $posted_username ) ) { + return; + } + + $this->unset_otp_session_variables(); + + $user = $this->get_user( trim( $posted_username ) ); $pwd_obj = $this->get_um_pwd_obj(); um_fetch_user( $user->ID ); $this->get_um_user_obj()->password_reset(); @@ -225,7 +225,7 @@ ); } $data = MoUtility::mo_sanitize_array( wp_unslash( $_POST ) ); - $otp_type = MoUtility::sanitize_check( 'otpType', $data ); + $otp_type = MoUtility::sanitize_check( 'otptype', $data ); if ( VerificationType::EMAIL === $otp_type ) { $data['user_phone'] = ''; } elseif ( VerificationType::PHONE === $otp_type ) { @@ -380,9 +380,9 @@ */ private function check_integrity_and_validate_otp( $data ) { $this->check_integrity( $data ); - $this->validate_challenge( sanitize_text_field( $data['otpType'] ), null, sanitize_text_field( $data['otp_token'] ) ); - if ( SessionUtils::is_status_match( $this->form_session_var, self::VALIDATED, $data['otpType'] ) ) { - MoPHPSessions::add_session_var( 'is_otp_verified_' . $data['otpType'], true ); + $this->validate_challenge( sanitize_text_field( $data['otptype'] ), null, sanitize_text_field( $data['otp_token'] ) ); + if ( SessionUtils::is_status_match( $this->form_session_var, self::VALIDATED, $data['otptype'] ) ) { + MoPHPSessions::add_session_var( 'is_otp_verified_' . $data['otptype'], true ); wp_send_json( MoUtility::create_json( MoConstants::SUCCESS_JSON_TYPE, @@ -405,7 +405,7 @@ * @param array $data - post data submitted on validate OTP button. */ private function check_integrity( $data ) { - if ( VerificationType::PHONE === $data['otpType'] ) { + if ( VerificationType::PHONE === $data['otptype'] ) { $phone = MoUtility::process_phone_number( sanitize_text_field( $data['user_phone'] ) ); if ( ! SessionUtils::is_phone_verified_match( $this->form_session_var, $phone ) ) { wp_send_json( @@ -416,7 +416,7 @@ ); } } - if ( VerificationType::EMAIL === $data['otpType'] ) { + if ( VerificationType::EMAIL === $data['otptype'] ) { if ( ! SessionUtils::is_email_verified_match( $this->form_session_var, sanitize_email( $data['user_email'] ) ) ) { wp_send_json( MoUtility::create_json( @@ -577,7 +577,7 @@ if ( empty( $otp_token ) && isset( $data['order_verify'] ) ) { $otp_token = $data['order_verify']; } - $this->validate_challenge( $data['otpType'], null, $otp_token ); + $this->validate_challenge( $data['otptype'], null, $otp_token ); } /** @@ -586,7 +586,7 @@ * @param array $data - this is the get / post data from the ajax call containing email or phone. */ private function checkIntegrity( $data ) { - if ( 'phone' === $data['otpType'] ) { + if ( 'phone' === $data['otptype'] ) { if ( ! SessionUtils::is_phone_verified_match( $this->form_session_var, MoUtility::process_phone_number( $data['user_phone'] ) ) ) { wp_send_json( MoUtility::create_json( @@ -136,7 +136,7 @@ $post_data = MoUtility::mo_sanitize_array( $_POST ); MoUtility::initialize_transaction( $this->form_session_var ); - if ( isset( $post_data['otpType'] ) && 'mo_wpform_' . $post_data['otpType'] . '_enable' === $this->type_phone_tag ) { + if ( isset( $post_data['otptype'] ) && 'mo_wpform_' . $post_data['otptype'] . '_enable' === $this->type_phone_tag ) { $this->process_phone_and_send_otp( $post_data ); } else { $this->process_email_and_send_otp( $post_data ); @@ -225,9 +225,9 @@ private function check_integrity_and_validate_otp( $data ) { $this->check_integrity( $data ); - $this->validate_challenge( $data['otpType'], null, $data['otp_token'] ); + $this->validate_challenge( $data['otptype'], null, $data['otp_token'] ); - if ( SessionUtils::is_status_match( $this->form_session_var, self::VALIDATED, $data['otpType'] ) ) { + if ( SessionUtils::is_status_match( $this->form_session_var, self::VALIDATED, $data['otptype'] ) ) { wp_send_json( MoUtility::create_json( MoConstants::SUCCESS_JSON_TYPE, @@ -250,7 +250,7 @@ * @param array $data - this is the get / post data from the ajax call containing email or phone. */ private function check_integrity( $data ) { - if ( 'phone' === $data['otpType'] ) { + if ( 'phone' === $data['otptype'] ) { $phone = MoUtility::process_phone_number( $data['user_phone'] ); if ( ! SessionUtils::is_phone_verified_match( $this->form_session_var, $phone ) ) { wp_send_json( @@ -511,15 +511,16 @@ if ( $skip_otp_process || $this->mo_delay_otp_process( $user->data->ID ) ) { return true; } - if ( - $this->by_pass_admin - && $this->skip_password_check - && ! $this->skip_pass_fallback - && 'password' === $this->mo_get_wp_login_intent() - ) { + if ( $this->by_pass_admin ) { $user_meta = get_userdata( $user->data->ID ); - $user_role = $user_meta->roles; - return in_array( 'administrator', $user_role, true ); + if ( in_array( 'administrator', $user_meta->roles, true ) ) { + // OTP-only mode: admin must use the password-intent link from the OTP popup. + if ( $this->skip_password_check && ! $this->skip_pass_fallback ) { + return 'password' === $this->mo_get_wp_login_intent(); + } + // 2FA mode: password already verified — bypass OTP for admins. + return true; + } } return false; } @@ -1257,6 +1258,9 @@ } if ( ! $this->mo_save_phone_numbers() ) { + if ( ! empty( $this->form_name ) ) { + MoPHPSessions::add_session_var( 'current_form_name', $this->form_name ); + } miniorange_site_otp_validation_form( null, null, @@ -1,611 +1,611 @@ -<?php -/** - * Load administrator changes for MoPHPSessions - * - * @package miniorange-otp-verification/helper - */ - -namespace OTP\Helper; - -if ( ! defined( 'ABSPATH' ) ) { - exit; -} - -use OTP\Objects\IMoSessions; - -/** TODO: Need to move each session type to different files */ -if ( ! class_exists( 'MoPHPSessions' ) ) { - /** - * Class for managing different types of session storage mechanisms. - * - * Implements the IMoSessions interface to provide consistent session handling - * across different storage types: PHP sessions, cookies, cache and transients. - * - * AUTO mode (recommended) picks SESSION or TRANSIENT per browser and falls back - * automatically when the primary mechanism cannot read/write OTP state. - */ - class MoPHPSessions implements IMoSessions { - - const STORAGE_MODE_COOKIE = 'mo_otp_storage_mode'; - - /** - * Resolved storage type for the current request (SESSION or TRANSIENT when using AUTO/fallback). - * - * @var string|null - */ - private static $resolved_type = null; - - /** - * Whether PHP session viability was probed this request. - * - * @var bool - */ - private static $session_viability_checked = false; - - /** - * Cached result of PHP session viability probe. - * - * @var bool - */ - private static $session_viable = false; - - /** - * Whether shutdown/REST hooks were registered to close PHP sessions. - * - * @var bool - */ - private static $close_hooks_registered = false; - - /** - * Bootstraps storage early so cookies can be set before output (fixes TRANSIENT on cached themes). - * - * @return void - */ - public static function bootstrap() { - if ( ! defined( 'MOV_SESSION_TYPE' ) ) { - return; - } - - self::register_session_close_hooks(); - - if ( self::should_cross_fallback() ) { - self::ensure_transient_cookie(); - } - } - - /** - * Registers hooks so PHP sessions are closed before REST/loopback HTTP requests (Site Health). - * - * @return void - */ - private static function register_session_close_hooks() { - if ( self::$close_hooks_registered || ! \function_exists( 'add_action' ) ) { - return; - } - - self::$close_hooks_registered = true; - \add_action( 'rest_api_init', array( __CLASS__, 'close_php_session' ), 0 ); - \add_filter( 'pre_http_request', array( __CLASS__, 'close_php_session_before_http' ), 1, 3 ); - \add_action( 'shutdown', array( __CLASS__, 'close_php_session' ), 0 ); - } - - /** - * Closes PHP session before WordPress loopback/REST HTTP requests (Site Health). - * - * @param false|array|\WP_Error $preempt Whether to preempt an HTTP request's return value. - * @param array $args HTTP request arguments. - * @param string $url The request URL. - * @return false|array|\WP_Error - */ - public static function close_php_session_before_http( $preempt, $args, $url ) { - unset( $args, $url ); - self::close_php_session(); - return $preempt; - } - - /** - * Sets session values based on the configured session type. - * - * @param string $key Key to store data under. - * @param mixed $val Value to store. - * @return void - */ - public static function add_session_var( $key, $val ) { - if ( empty( $key ) ) { - return; - } - - $storage_type = self::get_configured_storage_type(); - if ( self::should_cross_fallback() ) { - $primary = self::get_effective_storage_type(); - if ( ! self::write_by_type( $primary, $key, $val ) ) { - $alternate = self::get_alternate_storage_type( $primary ); - if ( self::write_by_type( $alternate, $key, $val ) ) { - self::persist_storage_preference( $alternate ); - } - } - return; - } - - self::write_by_type( $storage_type, $key, $val ); - } - - /** - * Retrieves a value stored in session by key. - * - * @param string $key Key used to store the value. - * @return mixed Value stored under the key, or null if not found. - */ - public static function get_session_var( $key ) { - if ( empty( $key ) ) { - return; - } - - if ( self::should_cross_fallback() ) { - $primary = self::get_effective_storage_type(); - $value = self::read_by_type( $primary, $key ); - if ( null !== $value ) { - return $value; - } - return self::read_by_type( self::get_alternate_storage_type( $primary ), $key ); - } - - return self::read_by_type( self::get_configured_storage_type(), $key ); - } - - /** - * Unsets session values for the specified key. - * - * @param string $key Key to unset from the session. - * @return void - */ - public static function unset_session( $key ) { - if ( self::should_cross_fallback() ) { - self::unset_by_type( 'SESSION', $key ); - self::unset_by_type( 'TRANSIENT', $key ); - return; - } - - self::unset_by_type( self::get_configured_storage_type(), $key ); - } - - /** - * Legacy no-op: PHP sessions are opened and closed per operation in this class. - * - * @return void - */ - public static function check_session() { - // Intentionally empty. Use open/close helpers inside MoPHPSessions only. - } - - /** - * Closes an active PHP session so REST API and loopback requests are not blocked. - * - * @return void - */ - public static function close_php_session() { - if ( \function_exists( 'session_status' ) && PHP_SESSION_ACTIVE === \session_status() ) { - \session_write_close(); - } - } - - /** - * Opens PHP session for reading and releases the lock when possible. - * - * @return bool - */ - private static function open_php_session_for_read() { - self::register_session_close_hooks(); - - if ( PHP_SESSION_DISABLED === \session_status() ) { - return false; - } - - if ( PHP_SESSION_ACTIVE === \session_status() ) { - return isset( $_SESSION ); - } - - if ( \headers_sent() ) { - return false; - } - - if ( \PHP_VERSION_ID >= 70000 ) { - return @\session_start( array( 'read_and_close' => true ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged - } - - if ( @\session_start() ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged - return isset( $_SESSION ); - } - - return false; - } - - /** - * Opens PHP session for writing. - * - * @return bool - */ - private static function open_php_session_for_write() { - self::register_session_close_hooks(); - - if ( PHP_SESSION_DISABLED === \session_status() ) { - return false; - } - - if ( PHP_SESSION_ACTIVE === \session_status() ) { - return isset( $_SESSION ); - } - - if ( \headers_sent() ) { - return false; - } - - return @\session_start(); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged - } - - /** - * Whether the current request uses native PHP $_SESSION storage. - * - * @return bool - */ - public static function uses_php_session() { - if ( ! self::should_cross_fallback() ) { - return 'SESSION' === self::get_configured_storage_type(); - } - return 'SESSION' === self::get_effective_storage_type(); - } - - /** - * Returns configured MOV_SESSION_TYPE (may be AUTO). - * - * @return string - */ - private static function get_configured_storage_type() { - return defined( 'MOV_SESSION_TYPE' ) ? strtoupper( (string) MOV_SESSION_TYPE ) : 'SESSION'; - } - - /** - * SESSION / TRANSIENT / AUTO use adaptive storage with cross-fallback. - * - * @return bool - */ - private static function should_cross_fallback() { - return in_array( self::get_configured_storage_type(), array( 'SESSION', 'TRANSIENT', 'AUTO' ), true ); - } - - /** - * Resolves effective storage (SESSION or TRANSIENT) for this request. - * - * @return string - */ - private static function get_effective_storage_type() { - if ( null !== self::$resolved_type ) { - return self::$resolved_type; - } - - $configured = self::get_configured_storage_type(); - $sticky = self::get_sticky_storage_mode(); - - if ( 'AUTO' === $configured ) { - self::$resolved_type = $sticky ? $sticky : self::detect_auto_storage_type(); - } elseif ( $sticky ) { - self::$resolved_type = $sticky; - } else { - self::$resolved_type = in_array( $configured, array( 'SESSION', 'TRANSIENT' ), true ) ? $configured : 'TRANSIENT'; - } - - /** - * Filter the storage backend used for OTP session data. - * - * @param string $storage_type SESSION or TRANSIENT. - */ - return (string) \apply_filters( 'mo_otp_storage_type', self::$resolved_type ); - } - - /** - * Reads sticky per-browser storage preference set after a fallback. - * - * @return string|null SESSION or TRANSIENT. - */ - private static function get_sticky_storage_mode() { - if ( empty( $_COOKIE[ self::STORAGE_MODE_COOKIE ] ) ) { - return null; - } - $mode = strtoupper( \sanitize_text_field( \wp_unslash( $_COOKIE[ self::STORAGE_MODE_COOKIE ] ) ) ); - return in_array( $mode, array( 'SESSION', 'TRANSIENT' ), true ) ? $mode : null; - } - - /** - * Persists which storage type worked for this browser (12 hours). - * - * @param string $storage_type SESSION or TRANSIENT. - * @return void - */ - private static function persist_storage_preference( $storage_type ) { - $storage_type = strtoupper( (string) $storage_type ); - if ( ! in_array( $storage_type, array( 'SESSION', 'TRANSIENT' ), true ) || \headers_sent() || ! self::can_set_cookie() ) { - return; - } - - self::$resolved_type = $storage_type; - \setcookie( self::STORAGE_MODE_COOKIE, $storage_type, time() + ( 12 * \HOUR_IN_SECONDS ), \COOKIEPATH, \COOKIE_DOMAIN, \is_ssl(), true ); - $_COOKIE[ self::STORAGE_MODE_COOKIE ] = $storage_type; - } - - /** - * Picks a default storage type when MOV_SESSION_TYPE is AUTO. - * - * @return string - */ - private static function detect_auto_storage_type() { - $session_ok = self::can_use_session_storage(); - $transient_ok = self::can_use_transient_storage(); - - if ( $session_ok && ! $transient_ok ) { - return 'SESSION'; - } - if ( $transient_ok && ! $session_ok ) { - return 'TRANSIENT'; - } - if ( $session_ok && $transient_ok ) { - /** - * Preferred storage when both SESSION and TRANSIENT are viable (AUTO mode). - * - * @param string $preferred Default TRANSIENT for WordPress hosting compatibility. - */ - return (string) \apply_filters( 'mo_otp_preferred_storage_type', 'TRANSIENT' ); - } - - return 'TRANSIENT'; - } - - /** - * Returns the other OTP storage backend. - * - * @param string $primary SESSION or TRANSIENT. - * @return string - */ - private static function get_alternate_storage_type( $primary ) { - return 'SESSION' === $primary ? 'TRANSIENT' : 'SESSION'; - } - - /** - * Whether native PHP sessions can be used on this request. - * - * @return bool - */ - private static function can_use_session_storage() { - if ( self::$session_viability_checked ) { - return self::$session_viable; - } - - self::$session_viability_checked = true; - self::$session_viable = false; - - if ( PHP_SESSION_DISABLED === session_status() ) { - return false; - } - - if ( \headers_sent() && PHP_SESSION_ACTIVE !== session_status() && '' === session_id() ) { - return false; - } - - if ( PHP_SESSION_ACTIVE === session_status() || '' !== session_id() ) { - self::$session_viable = isset( $_SESSION ); - return self::$session_viable; - } - - if ( \headers_sent() ) { - return false; - } - - if ( self::open_php_session_for_write() ) { - $probe_key = 'mo_otp_storage_probe'; - $_SESSION[ $probe_key ] = '1'; - self::$session_viable = isset( $_SESSION[ $probe_key ] ); - unset( $_SESSION[ $probe_key ] ); - self::close_php_session(); - } - - return self::$session_viable; - } - - /** - * Whether WordPress transients (with cookie key) can be used on this request. - * - * @return bool - */ - private static function can_use_transient_storage() { - if ( ! empty( $_COOKIE['transient_key'] ) ) { - return true; - } - - return ! \headers_sent() && self::can_set_cookie(); - } - - /** - * Ensures transient_key cookie exists for the current request. - * - * @return void - */ - private static function ensure_transient_cookie() { - self::get_transient_key(); - } - - /** - * Returns the transient storage cookie value, creating it when possible. - * - * @return string|null - */ - private static function get_transient_key() { - if ( ! empty( $_COOKIE['transient_key'] ) ) { - return \sanitize_text_field( \wp_unslash( $_COOKIE['transient_key'] ) ); - } - - if ( \headers_sent() || ! self::can_set_cookie() ) { - return null; - } - - $transient_key = self::generate_transient_key(); - if ( ! $transient_key ) { - return null; - } - - $_COOKIE['transient_key'] = $transient_key; - \setcookie( 'transient_key', $transient_key, time() + ( 12 * \HOUR_IN_SECONDS ), \COOKIEPATH, \COOKIE_DOMAIN, \is_ssl(), true ); - - return $transient_key; - } - - /** - * Whether WordPress cookie constants are available for setcookie(). - * - * @return bool - */ - private static function can_set_cookie() { - return defined( 'COOKIEPATH' ) && defined( 'COOKIE_DOMAIN' ); - } - - /** - * Generates a random transient cookie key (safe before pluggable.php is loaded). - * - * @return string|null - */ - private static function generate_transient_key() { - if ( \function_exists( 'wp_generate_password' ) ) { - return \wp_generate_password( 32, false ); - } - - if ( \function_exists( 'random_bytes' ) ) { - return \bin2hex( \random_bytes( 16 ) ); - } - - return \md5( \uniqid( 'mo_otp', true ) ); - } - - /** - * Writes OTP state to a specific storage backend. - * - * @param string $storage_type Storage backend. - * @param string $key Session key. - * @param mixed $val Value to store. - * @return bool - */ - private static function write_by_type( $storage_type, $key, $val ) { - switch ( $storage_type ) { - case 'SESSION': - if ( ! self::open_php_session_for_write() || ! isset( $_SESSION ) ) { - return false; - } - $_SESSION[ $key ] = \maybe_serialize( $val ); - $written = \array_key_exists( $key, $_SESSION ); - self::close_php_session(); - return $written; - case 'TRANSIENT': - $transient_key = self::get_transient_key(); - if ( ! $transient_key ) { - return false; - } - return false !== \set_site_transient( 'mo_otp_' . $transient_key . $key, $val, 12 * \HOUR_IN_SECONDS ); - case 'COOKIE': - if ( \headers_sent() ) { - return false; - } - $cookie_val = \wp_json_encode( $val, JSON_UNESCAPED_SLASHES ); - \setcookie( $key, $cookie_val, time() + ( 12 * \HOUR_IN_SECONDS ), \COOKIEPATH, \COOKIE_DOMAIN, \is_ssl(), true ); - $_COOKIE[ $key ] = $cookie_val; - return true; - case 'CACHE': - if ( ! \wp_cache_add( $key, \maybe_serialize( $val ) ) ) { - \wp_cache_replace( $key, \maybe_serialize( $val ) ); - } - return false !== \wp_cache_get( $key ); - } - - return false; - } - - /** - * Reads OTP state from a specific storage backend. - * - * @param string $storage_type Storage backend. - * @param string $key Session key. - * @return mixed|null - */ - private static function read_by_type( $storage_type, $key ) { - switch ( $storage_type ) { - case 'SESSION': - if ( ! self::open_php_session_for_read() || ! isset( $_SESSION ) ) { - return null; - } - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Internal session payload written by this plugin. - $raw = isset( $_SESSION[ $key ] ) ? $_SESSION[ $key ] : null; - if ( \function_exists( 'session_status' ) && PHP_SESSION_ACTIVE === \session_status() ) { - self::close_php_session(); - } - if ( null === $raw ) { - return null; - } - return \maybe_unserialize( $raw ); - case 'TRANSIENT': - if ( empty( $_COOKIE['transient_key'] ) ) { - return null; - } - $transient_key = \sanitize_text_field( \wp_unslash( $_COOKIE['transient_key'] ) ); - $value = \get_site_transient( 'mo_otp_' . $transient_key . $key ); - return ( false === $value ) ? null : $value; - case 'COOKIE': - $raw = isset( $_COOKIE[ $key ] ) ? \sanitize_text_field( \wp_unslash( $_COOKIE[ $key ] ) ) : null; - if ( null === $raw ) { - return null; - } - $decoded = json_decode( $raw, true ); - if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) { - return null; - } - return $decoded; - case 'CACHE': - $raw = \wp_cache_get( $key ); - if ( null === $raw ) { - return null; - } - return \maybe_unserialize( $raw ); - } - - return null; - } - - /** - * Removes OTP state from a specific storage backend. - * - * @param string $storage_type Storage backend. - * @param string $key Session key. - * @return void - */ - private static function unset_by_type( $storage_type, $key ) { - switch ( $storage_type ) { - case 'SESSION': - if ( self::open_php_session_for_write() && isset( $_SESSION[ $key ] ) ) { - unset( $_SESSION[ $key ] ); - } - self::close_php_session(); - break; - case 'TRANSIENT': - if ( ! empty( $_COOKIE['transient_key'] ) ) { - $transient_key = \sanitize_text_field( \wp_unslash( $_COOKIE['transient_key'] ) ); - \delete_site_transient( 'mo_otp_' . $transient_key . $key ); - } - break; - case 'COOKIE': - unset( $_COOKIE[ $key ] ); - if ( ! \headers_sent() ) { - \setcookie( $key, '', time() - ( 15 * 60 ), \COOKIEPATH, \COOKIE_DOMAIN, \is_ssl(), true ); - } - break; - case 'CACHE': - \wp_cache_delete( $key ); - break; - } - } - } -} +<?php +/** + * Load administrator changes for MoPHPSessions + * + * @package miniorange-otp-verification/helper + */ + +namespace OTP\Helper; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +use OTP\Objects\IMoSessions; + +/** TODO: Need to move each session type to different files */ +if ( ! class_exists( 'MoPHPSessions' ) ) { + /** + * Class for managing different types of session storage mechanisms. + * + * Implements the IMoSessions interface to provide consistent session handling + * across different storage types: PHP sessions, cookies, cache and transients. + * + * AUTO mode (recommended) picks SESSION or TRANSIENT per browser and falls back + * automatically when the primary mechanism cannot read/write OTP state. + */ + class MoPHPSessions implements IMoSessions { + + const STORAGE_MODE_COOKIE = 'mo_otp_storage_mode'; + + /** + * Resolved storage type for the current request (SESSION or TRANSIENT when using AUTO/fallback). + * + * @var string|null + */ + private static $resolved_type = null; + + /** + * Whether PHP session viability was probed this request. + * + * @var bool + */ + private static $session_viability_checked = false; + + /** + * Cached result of PHP session viability probe. + * + * @var bool + */ + private static $session_viable = false; + + /** + * Whether shutdown/REST hooks were registered to close PHP sessions. + * + * @var bool + */ + private static $close_hooks_registered = false; + + /** + * Bootstraps storage early so cookies can be set before output (fixes TRANSIENT on cached themes). + * + * @return void + */ + public static function bootstrap() { + if ( ! defined( 'MOV_SESSION_TYPE' ) ) { + return; + } + + self::register_session_close_hooks(); + + if ( self::should_cross_fallback() ) { + self::ensure_transient_cookie(); + } + } + + /** + * Registers hooks so PHP sessions are closed before REST/loopback HTTP requests (Site Health). + * + * @return void + */ + private static function register_session_close_hooks() { + if ( self::$close_hooks_registered || ! \function_exists( 'add_action' ) ) { + return; + } + + self::$close_hooks_registered = true; + \add_action( 'rest_api_init', array( __CLASS__, 'close_php_session' ), 0 ); + \add_filter( 'pre_http_request', array( __CLASS__, 'close_php_session_before_http' ), 1, 3 ); + \add_action( 'shutdown', array( __CLASS__, 'close_php_session' ), 0 ); + } + + /** + * Closes PHP session before WordPress loopback/REST HTTP requests (Site Health). + * + * @param false|array|\WP_Error $preempt Whether to preempt an HTTP request's return value. + * @param array $args HTTP request arguments. + * @param string $url The request URL. + * @return false|array|\WP_Error + */ + public static function close_php_session_before_http( $preempt, $args, $url ) { + unset( $args, $url ); + self::close_php_session(); + return $preempt; + } + + /** + * Sets session values based on the configured session type. + * + * @param string $key Key to store data under. + * @param mixed $val Value to store. + * @return void + */ + public static function add_session_var( $key, $val ) { + if ( empty( $key ) ) { + return; + } + + $storage_type = self::get_configured_storage_type(); + if ( self::should_cross_fallback() ) { + $primary = self::get_effective_storage_type(); + if ( ! self::write_by_type( $primary, $key, $val ) ) { + $alternate = self::get_alternate_storage_type( $primary ); + if ( self::write_by_type( $alternate, $key, $val ) ) { + self::persist_storage_preference( $alternate ); + } + } + return; + } + + self::write_by_type( $storage_type, $key, $val ); + } + + /** + * Retrieves a value stored in session by key. + * + * @param string $key Key used to store the value. + * @return mixed Value stored under the key, or null if not found. + */ + public static function get_session_var( $key ) { + if ( empty( $key ) ) { + return; + } + + if ( self::should_cross_fallback() ) { + $primary = self::get_effective_storage_type(); + $value = self::read_by_type( $primary, $key ); + if ( null !== $value ) { + return $value; + } + return self::read_by_type( self::get_alternate_storage_type( $primary ), $key ); + } + + return self::read_by_type( self::get_configured_storage_type(), $key ); + } + + /** + * Unsets session values for the specified key. + * + * @param string $key Key to unset from the session. + * @return void + */ + public static function unset_session( $key ) { + if ( self::should_cross_fallback() ) { + self::unset_by_type( 'SESSION', $key ); + self::unset_by_type( 'TRANSIENT', $key ); + return; + } + + self::unset_by_type( self::get_configured_storage_type(), $key ); + } + + /** + * Legacy no-op: PHP sessions are opened and closed per operation in this class. + * + * @return void + */ + public static function check_session() { + // Intentionally empty. Use open/close helpers inside MoPHPSessions only. + } + + /** + * Closes an active PHP session so REST API and loopback requests are not blocked. + * + * @return void + */ + public static function close_php_session() { + if ( \function_exists( 'session_status' ) && PHP_SESSION_ACTIVE === \session_status() ) { + \session_write_close(); + } + } + + /** + * Opens PHP session for reading and releases the lock when possible. + * + * @return bool + */ + private static function open_php_session_for_read() { + self::register_session_close_hooks(); + + if ( PHP_SESSION_DISABLED === \session_status() ) { + return false; + } + + if ( PHP_SESSION_ACTIVE === \session_status() ) { + return isset( $_SESSION ); + } + + if ( \headers_sent() ) { + return false; + } + + if ( \PHP_VERSION_ID >= 70000 ) { + return @\session_start( array( 'read_and_close' => true ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + } + + if ( @\session_start() ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + return isset( $_SESSION ); + } + + return false; + } + + /** + * Opens PHP session for writing. + * + * @return bool + */ + private static function open_php_session_for_write() { + self::register_session_close_hooks(); + + if ( PHP_SESSION_DISABLED === \session_status() ) { + return false; + } + + if ( PHP_SESSION_ACTIVE === \session_status() ) { + return isset( $_SESSION ); + } + + if ( \headers_sent() ) { + return false; + } + + return @\session_start(); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + } + + /** + * Whether the current request uses native PHP $_SESSION storage. + * + * @return bool + */ + public static function uses_php_session() { + if ( ! self::should_cross_fallback() ) { + return 'SESSION' === self::get_configured_storage_type(); + } + return 'SESSION' === self::get_effective_storage_type(); + } + + /** + * Returns configured MOV_SESSION_TYPE (may be AUTO). + * + * @return string + */ + private static function get_configured_storage_type() { + return defined( 'MOV_SESSION_TYPE' ) ? strtoupper( (string) MOV_SESSION_TYPE ) : 'SESSION'; + } + + /** + * SESSION / TRANSIENT / AUTO use adaptive storage with cross-fallback. + * + * @return bool + */ + private static function should_cross_fallback() { + return in_array( self::get_configured_storage_type(), array( 'SESSION', 'TRANSIENT', 'AUTO' ), true ); + } + + /** + * Resolves effective storage (SESSION or TRANSIENT) for this request. + * + * @return string + */ + private static function get_effective_storage_type() { + if ( null !== self::$resolved_type ) { + return self::$resolved_type; + } + + $configured = self::get_configured_storage_type(); + $sticky = self::get_sticky_storage_mode(); + + if ( 'AUTO' === $configured ) { + self::$resolved_type = $sticky ? $sticky : self::detect_auto_storage_type(); + } elseif ( $sticky ) { + self::$resolved_type = $sticky; + } else { + self::$resolved_type = in_array( $configured, array( 'SESSION', 'TRANSIENT' ), true ) ? $configured : 'TRANSIENT'; + } + + /** + * Filter the storage backend used for OTP session data. + * + * @param string $storage_type SESSION or TRANSIENT. + */ + return (string) \apply_filters( 'mo_otp_storage_type', self::$resolved_type ); + } + + /** + * Reads sticky per-browser storage preference set after a fallback. + * + * @return string|null SESSION or TRANSIENT. + */ + private static function get_sticky_storage_mode() { + if ( empty( $_COOKIE[ self::STORAGE_MODE_COOKIE ] ) ) { + return null; + } + $mode = strtoupper( \sanitize_text_field( \wp_unslash( $_COOKIE[ self::STORAGE_MODE_COOKIE ] ) ) ); + return in_array( $mode, array( 'SESSION', 'TRANSIENT' ), true ) ? $mode : null; + } + + /** + * Persists which storage type worked for this browser (12 hours). + * + * @param string $storage_type SESSION or TRANSIENT. + * @return void + */ + private static function persist_storage_preference( $storage_type ) { + $storage_type = strtoupper( (string) $storage_type ); + if ( ! in_array( $storage_type, array( 'SESSION', 'TRANSIENT' ), true ) || \headers_sent() || ! self::can_set_cookie() ) { + return; + } + + self::$resolved_type = $storage_type; + \setcookie( self::STORAGE_MODE_COOKIE, $storage_type, time() + ( 12 * \HOUR_IN_SECONDS ), \COOKIEPATH, \COOKIE_DOMAIN, \is_ssl(), true ); + $_COOKIE[ self::STORAGE_MODE_COOKIE ] = $storage_type; + } + + /** + * Picks a default storage type when MOV_SESSION_TYPE is AUTO. + * + * @return string + */ + private static function detect_auto_storage_type() { + $session_ok = self::can_use_session_storage(); + $transient_ok = self::can_use_transient_storage(); + + if ( $session_ok && ! $transient_ok ) { + return 'SESSION'; + } + if ( $transient_ok && ! $session_ok ) { + return 'TRANSIENT'; + } + if ( $session_ok && $transient_ok ) { + /** + * Preferred storage when both SESSION and TRANSIENT are viable (AUTO mode). + * + * @param string $preferred Default TRANSIENT for WordPress hosting compatibility. + */ + return (string) \apply_filters( 'mo_otp_preferred_storage_type', 'TRANSIENT' ); + } + + return 'TRANSIENT'; + } + + /** + * Returns the other OTP storage backend. + * + * @param string $primary SESSION or TRANSIENT. + * @return string + */ + private static function get_alternate_storage_type( $primary ) { + return 'SESSION' === $primary ? 'TRANSIENT' : 'SESSION'; + } + + /** + * Whether native PHP sessions can be used on this request. + * + * @return bool + */ + private static function can_use_session_storage() { + if ( self::$session_viability_checked ) { + return self::$session_viable; + } + + self::$session_viability_checked = true; + self::$session_viable = false; + + if ( PHP_SESSION_DISABLED === session_status() ) { + return false; + } + + if ( \headers_sent() && PHP_SESSION_ACTIVE !== session_status() && '' === session_id() ) { + return false; + } + + if ( PHP_SESSION_ACTIVE === session_status() || '' !== session_id() ) { + self::$session_viable = isset( $_SESSION ); + return self::$session_viable; + } + + if ( \headers_sent() ) { + return false; + } + + if ( self::open_php_session_for_write() ) { + $probe_key = 'mo_otp_storage_probe'; + $_SESSION[ $probe_key ] = '1'; + self::$session_viable = isset( $_SESSION[ $probe_key ] ); + unset( $_SESSION[ $probe_key ] ); + self::close_php_session(); + } + + return self::$session_viable; + } + + /** + * Whether WordPress transients (with cookie key) can be used on this request. + * + * @return bool + */ + private static function can_use_transient_storage() { + if ( ! empty( $_COOKIE['transient_key'] ) ) { + return true; + } + + return ! \headers_sent() && self::can_set_cookie(); + } + + /** + * Ensures transient_key cookie exists for the current request. + * + * @return void + */ + private static function ensure_transient_cookie() { + self::get_transient_key(); + } + + /** + * Returns the transient storage cookie value, creating it when possible. + * + * @return string|null + */ + private static function get_transient_key() { + if ( ! empty( $_COOKIE['transient_key'] ) ) { + return \sanitize_text_field( \wp_unslash( $_COOKIE['transient_key'] ) ); + } + + if ( \headers_sent() || ! self::can_set_cookie() ) { + return null; + } + + $transient_key = self::generate_transient_key(); + if ( ! $transient_key ) { + return null; + } + + $_COOKIE['transient_key'] = $transient_key; + \setcookie( 'transient_key', $transient_key, time() + ( 12 * \HOUR_IN_SECONDS ), \COOKIEPATH, \COOKIE_DOMAIN, \is_ssl(), true ); + + return $transient_key; + } + + /** + * Whether WordPress cookie constants are available for setcookie(). + * + * @return bool + */ + private static function can_set_cookie() { + return defined( 'COOKIEPATH' ) && defined( 'COOKIE_DOMAIN' ); + } + + /** + * Generates a random transient cookie key (safe before pluggable.php is loaded). + * + * @return string|null + */ + private static function generate_transient_key() { + if ( \function_exists( 'wp_generate_password' ) ) { + return \wp_generate_password( 32, false ); + } + + if ( \function_exists( 'random_bytes' ) ) { + return \bin2hex( \random_bytes( 16 ) ); + } + + return \md5( \uniqid( 'mo_otp', true ) ); + } + + /** + * Writes OTP state to a specific storage backend. + * + * @param string $storage_type Storage backend. + * @param string $key Session key. + * @param mixed $val Value to store. + * @return bool + */ + private static function write_by_type( $storage_type, $key, $val ) { + switch ( $storage_type ) { + case 'SESSION': + if ( ! self::open_php_session_for_write() || ! isset( $_SESSION ) ) { + return false; + } + $_SESSION[ $key ] = \maybe_serialize( $val ); + $written = \array_key_exists( $key, $_SESSION ); + self::close_php_session(); + return $written; + case 'TRANSIENT': + $transient_key = self::get_transient_key(); + if ( ! $transient_key ) { + return false; + } + return false !== \set_site_transient( 'mo_otp_' . $transient_key . $key, $val, 12 * \HOUR_IN_SECONDS ); + case 'COOKIE': + if ( \headers_sent() ) { + return false; + } + $cookie_val = \wp_json_encode( $val, JSON_UNESCAPED_SLASHES ); + \setcookie( $key, $cookie_val, time() + ( 12 * \HOUR_IN_SECONDS ), \COOKIEPATH, \COOKIE_DOMAIN, \is_ssl(), true ); + $_COOKIE[ $key ] = $cookie_val; + return true; + case 'CACHE': + if ( ! \wp_cache_add( $key, \maybe_serialize( $val ) ) ) { + \wp_cache_replace( $key, \maybe_serialize( $val ) ); + } + return false !== \wp_cache_get( $key ); + } + + return false; + } + + /** + * Reads OTP state from a specific storage backend. + * + * @param string $storage_type Storage backend. + * @param string $key Session key. + * @return mixed|null + */ + private static function read_by_type( $storage_type, $key ) { + switch ( $storage_type ) { + case 'SESSION': + if ( ! self::open_php_session_for_read() || ! isset( $_SESSION ) ) { + return null; + } + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Internal session payload written by this plugin. + $raw = isset( $_SESSION[ $key ] ) ? $_SESSION[ $key ] : null; + if ( \function_exists( 'session_status' ) && PHP_SESSION_ACTIVE === \session_status() ) { + self::close_php_session(); + } + if ( null === $raw ) { + return null; + } + return \maybe_unserialize( $raw ); + case 'TRANSIENT': + if ( empty( $_COOKIE['transient_key'] ) ) { + return null; + } + $transient_key = \sanitize_text_field( \wp_unslash( $_COOKIE['transient_key'] ) ); + $value = \get_site_transient( 'mo_otp_' . $transient_key . $key ); + return ( false === $value ) ? null : $value; + case 'COOKIE': + $raw = isset( $_COOKIE[ $key ] ) ? \sanitize_text_field( \wp_unslash( $_COOKIE[ $key ] ) ) : null; + if ( null === $raw ) { + return null; + } + $decoded = json_decode( $raw, true ); + if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) { + return null; + } + return $decoded; + case 'CACHE': + $raw = \wp_cache_get( $key ); + if ( null === $raw ) { + return null; + } + return \maybe_unserialize( $raw ); + } + + return null; + } + + /** + * Removes OTP state from a specific storage backend. + * + * @param string $storage_type Storage backend. + * @param string $key Session key. + * @return void + */ + private static function unset_by_type( $storage_type, $key ) { + switch ( $storage_type ) { + case 'SESSION': + if ( self::open_php_session_for_write() && isset( $_SESSION[ $key ] ) ) { + unset( $_SESSION[ $key ] ); + } + self::close_php_session(); + break; + case 'TRANSIENT': + if ( ! empty( $_COOKIE['transient_key'] ) ) { + $transient_key = \sanitize_text_field( \wp_unslash( $_COOKIE['transient_key'] ) ); + \delete_site_transient( 'mo_otp_' . $transient_key . $key ); + } + break; + case 'COOKIE': + unset( $_COOKIE[ $key ] ); + if ( ! \headers_sent() ) { + \setcookie( $key, '', time() - ( 15 * 60 ), \COOKIEPATH, \COOKIE_DOMAIN, \is_ssl(), true ); + } + break; + case 'CACHE': + \wp_cache_delete( $key ); + break; + } + } + } +} @@ -1,1630 +1,1635 @@ -<?php -/** - * Load administrator changes for MoUtility - * - * @package miniorange-otp-verification/helper - */ - -namespace OTP\Helper; - -if ( ! defined( 'ABSPATH' ) ) { - exit; -} - -use OTP\Objects\NotificationSettings; -use OTP\Objects\TabDetails; -use OTP\Objects\Tabs; -use OTP\Objects\VerificationType; -use ReflectionClass; -use ReflectionException; -use stdClass; -use OTP\LicenseLibrary\Mo_License_Service; -use OTP\Helper\MoConstants; -use OTP\Helper\CountryList; - -/** - * This is the main Utility class of the plugin. - * Lists down all the necessary common utility - * functions being used in the plugin. - */ -if ( ! class_exists( 'MoUtility' ) ) { - /** - * MoUtility class - */ - class MoUtility { - - - /** - * Checking Script tags - * - * @param string $template checking script tag. - * @return string - */ - public static function check_for_script_tags( $template ) { - return preg_match( '/<script>/', $template, $match ); - } - - /** - * Sanitizing array - * - * @param array $data data array to be sanitized. - * @return array - */ - public static function mo_sanitize_array( $data ) { - $sanitized_data = array(); - foreach ( $data as $key => $value ) { - if ( is_array( $value ) ) { - $sanitized_data[ $key ] = self::mo_sanitize_array( $value ); - } else { - $sanitized_data[ $key ] = sanitize_text_field( wp_unslash( $value ) ); - } - } - return $sanitized_data; - } - - /** - * MoInternal Function - * - * @return array - */ - public static function mo_allow_html_array() { - $allowed_tags = array( - 'a' => array( - 'style' => array(), - 'class' => array(), - 'href' => array(), - 'rel' => array(), - 'title' => array(), - 'hidden' => array(), - 'target' => array(), - 'onclick' => array(), - ), - 'b' => array( - 'style' => array(), - 'class' => array(), - 'id' => array(), - ), - 'blockquote' => array( - 'cite' => array(), - ), - 'code' => array(), - 'del' => array( - 'datetime' => array(), - 'title' => array(), - ), - 'div' => array( - 'name' => array(), - 'dir' => array(), - 'id' => array(), - 'class' => array(), - 'title' => array(), - 'style' => array(), - 'hidden' => array(), - ), - 'dl' => array(), - 'dt' => array(), - 'em' => array(), - 'h1' => array(), - 'h2' => array(), - 'h3' => array(), - 'h4' => array(), - 'h5' => array(), - 'h6' => array(), - 'hr' => array(), - 'i' => array(), - 'textarea' => array( - 'id' => array(), - 'class' => array(), - 'name' => array(), - 'row' => array(), - 'style' => array(), - 'placeholder' => array(), - 'readonly' => array(), - ), - 'img' => array( - 'alt' => array(), - 'class' => array(), - 'height' => array(), - 'style' => array(), - 'src' => array(), - 'width' => array(), - 'href' => array(), - 'hidden' => array(), - ), - 'link' => array( - 'rel' => array(), - 'type' => array(), - 'href' => array(), - 'hidden' => array(), - ), - 'li' => array( - 'class' => array(), - 'hidden' => array(), - ), - 'ol' => array( - 'class' => array(), - ), - 'p' => array( - 'class' => array(), - 'hidden' => array(), - 'style' => array(), - ), - 'q' => array( - 'cite' => array(), - 'title' => array(), - ), - 'span' => array( - 'id' => array(), - 'value' => array(), - 'class' => array(), - 'title' => array(), - 'style' => array(), - 'hidden' => array(), - ), - 'strike' => array(), - 'strong' => array(), - 'u' => array(), - 'ul' => array( - 'class' => array(), - 'style' => array(), - ), - 'form' => array( - 'name' => array(), - 'method' => array(), - 'id' => array(), - 'style' => array(), - 'hidden' => array(), - ), - 'table' => array( - 'class' => array(), - 'style' => array(), - 'cellpadding' => array(), - 'cellspacing' => array(), - 'border' => array(), - 'width' => array(), - ), - 'tbody' => array(), - 'button' => array(), - 'tr' => array(), - 'td' => array( - 'class' => array(), - 'style' => array(), - ), - 'input' => array( - 'type' => array(), - 'id' => array(), - 'name' => array(), - 'value' => array(), - 'class' => array(), - 'size ' => array(), - 'tabindex' => array(), - 'hidden' => array(), - 'style' => array(), - 'placeholder' => array(), - 'disabled' => array(), - 'data-next' => array(), - 'data-previous' => array(), - 'maxlength' => array(), - ), - 'br' => array(), - 'title' => array( - 'title' => true, - ), - ); - return $allowed_tags; - } - - /** - * Allowing tags for popup templates - * - * @return array - */ - public static function mo_allow_popup_tags() { - $allowed_tags = array( - 'head' => array(), - 'title' => array(), - 'meta' => array( - 'http-equiv' => array(), - 'content' => array(), - 'name' => array(), - ), - 'html' => array(), - 'body' => array(), - 'style' => array( - 'type' => array(), - ), - 'div' => array( - 'name' => array(), - 'dir' => array(), - 'id' => array(), - 'class' => array(), - 'title' => array(), - 'style' => array(), - 'tabindex' => array(), - 'role' => array(), - 'hidden' => array(), - ), - 'link' => array( - 'href' => array(), - 'target' => array(), - 'rel' => array(), - 'type' => array(), - 'title' => array(), - 'hidden' => array(), - ), - 'input' => array( - 'type' => array(), - 'id' => array(), - 'name' => array(), - 'value' => array(), - 'class' => array(), - 'size ' => array(), - 'tabindex' => array(), - 'hidden' => array(), - ), - 'button' => array( - 'class' => array(), - 'id' => array(), - 'type' => array(), - 'name' => array(), - 'value' => array(), - ), - 'form' => array( - 'name' => array(), - 'method' => array(), - 'action' => array(), - 'id' => array(), - 'class' => array(), - 'hidden' => array(), - ), - 'br' => array(), - 'p' => array( - 'class' => true, - 'style' => true, - 'id' => true, - ), - 'i' => array(), - 'u' => array(), - 'span' => array( - 'id' => array(), - 'value' => array(), - 'class' => array(), - 'title' => array(), - 'style' => array(), - 'hidden' => array(), - ), - 'a' => array( - 'href' => true, - 'target' => true, - 'rel' => true, - 'title' => true, - 'hidden' => true, - 'class' => true, - 'onclick' => true, - ), - 'svg' => array( - 'class' => array(), - 'id' => array(), - 'width' => array(), - 'height' => array(), - 'viewBox' => array(), - 'viewbox' => array(), - 'fill' => array(), - ), - 'circle' => array( - 'id' => array(), - 'cx' => array(), - 'cy' => array(), - 'cz' => array(), - 'r' => array(), - ), - 'g' => array( - 'fill' => array(), - 'id' => array(), - ), - 'path' => array( - 'd' => array(), - 'fill' => array(), - 'stroke' => array(), - 'stroke-width' => array(), - 'stroke-linecap' => array(), - 'stroke-linejoin' => array(), - ), - 'rect' => array( - 'x' => array(), - 'y' => array(), - 'width' => array(), - 'height' => array(), - 'rx' => array(), - 'fill' => array(), - 'stroke' => array(), - 'stroke-width' => array(), - 'stroke-linejoin' => array(), - ), - 'defs' => array(), - 'lineargradient' => array( - 'id' => array(), - 'x1' => array(), - 'x2' => array(), - 'y1' => array(), - 'y2' => array(), - 'gradientunits' => array(), - ), - ); - return $allowed_tags; - } - - /** - * KSES allowlist for rendered OTP popup HTML: popup chrome (forms, svg) plus post-like tags - * in {{MESSAGE}} so links and paragraphs (e.g. admin password hint) survive both inner and outer wp_kses passes. - * - * @return array - */ - public static function mo_popup_html_kses_allowed() { - $popup = self::mo_allow_popup_tags(); - if ( ! function_exists( 'wp_kses_allowed_html' ) ) { - return $popup; - } - $post = wp_kses_allowed_html( 'post' ); - foreach ( $post as $tag => $post_attrs ) { - if ( ! isset( $popup[ $tag ] ) ) { - $popup[ $tag ] = $post_attrs; - continue; - } - $popup[ $tag ] = array_merge( (array) $post_attrs, (array) $popup[ $tag ] ); - } - return $popup; - } - - /** - * MoInternal Function - * - * @return array - */ - public static function mo_allow_svg_array() { - $allowed_tags = array( - 'svg' => array( - 'class' => true, - 'width' => true, - 'height' => true, - 'viewbox' => true, - 'fill' => true, - ), - 'circle' => array( - 'id' => true, - 'cx' => true, - 'cy' => true, - 'cz' => true, - 'r' => true, - 'stroke' => true, - 'stroke-width' => true, - ), - 'g' => array( - 'fill' => true, - 'id' => true, - ), - 'path' => array( - 'd' => true, - 'fill' => true, - 'id' => true, - 'fill-rule' => true, - 'clip-rule' => true, - 'stroke' => true, - 'stroke-width' => true, - 'stroke-linecap' => true, - ), - 'rect' => array( - 'width' => true, - 'height' => true, - 'rx' => true, - 'fill' => true, - ), - 'defs' => array(), - 'lineargradient' => array( - 'id' => true, - 'x1' => true, - 'x2' => true, - 'y1' => true, - 'y2' => true, - 'gradientunits' => true, - ), - 'stop' => array( - 'stop-color' => true, - 'offset' => true, - ), - ); - return $allowed_tags; - } - - - /** - * Masking the Phone Number of User - * - * @param string $phone Phone Number of the user. - */ - public static function mo_mask_phone_number( $phone ) { - $length = strlen( $phone ); - $masked_part = str_repeat( '*', max( 0, $length - 3 ) ); - $last_three = substr( $phone, -3 ); - return $masked_part . $last_three; - } - - - /** - * Masking the Email of User - * - * @param string $email email of the user. - */ - public static function mo_mask_email( $email ) { - $parts = explode( '@', $email ); - if ( count( $parts ) !== 2 ) { - return $email; - } - $username = $parts[0]; - $domain = $parts[1]; - $visible_part = substr( $username, 0, 2 ); - $masked_part = str_repeat( '*', max( 0, strlen( $username ) - 2 ) ); - return $visible_part . $masked_part . '@' . $domain; - } - - /** Process the phone number and get_hidden_phone. - * - * @param string $phone - the phone number to processed. - * - * @return string - */ - public static function get_hidden_phone( $phone ) { - return 'xxxxxxx' . substr( $phone, strlen( $phone ) - 3 ); - } - - - /** - * Process the value being passed and checks if it is empty or null - * - * @param string $value - the value to be checked. - * - * @return bool - */ - public static function is_blank( $value ) { - return ! isset( $value ) || empty( $value ); - } - - /** - * Process the plugin name is being passed and checks if it plugin is active or not - * - * @param string $plugin - the plugin name to be checked. - * - * @return bool - */ - public static function is_plugin_installed( $plugin ) { - if ( ! function_exists( 'is_plugin_active' ) ) { - include_once ABSPATH . 'wp-admin/includes/plugin.php'; - } - return is_plugin_active( $plugin ); - } - - - /** - * Creates and returns the JSON response. - * - * @param string $message - the message. - * @param string $type - the type of result ( success or error ). - * @return array - */ - public static function create_json( $message, $type ) { - return array( - 'message' => $message, - 'result' => $type, - ); - } - /** - * Check for Country Restriction Addon - * - * @param mixed $phone . - * @return bool - */ - public static function check_for_selected_country_addon( $phone ) { - $countriesavail = CountryList::get_countrycode_list(); - $countriesavail = apply_filters( 'selected_countries', $countriesavail ); - - foreach ( $countriesavail as $key => $value ) { - if ( 'All Countries' !== $value['name'] ) { - if ( strpos( $phone, $value['countryCode'] ) !== false ) { - return false; - } - } - } - return true; - } - - /** - * This function checks if cURL is installed on the server. - * - * @return bool - */ - public static function mo_is_curl_installed() { - return in_array( 'curl', get_loaded_extensions(), true ); - } - - - /** - * The function returns the current page URL. - * - * @return string - */ - public static function current_page_url() { - $page_url = 'http'; - - if ( ( isset( $_SERVER['HTTPS'] ) ) && ( sanitize_text_field( wp_unslash( $_SERVER['HTTPS'] ) ) === 'on' ) ) { - $page_url .= 's'; - } - - $page_url .= '://'; - - $server_port = isset( $_SERVER['SERVER_PORT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_PORT'] ) ) : ''; - $server_uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; - $server_name = isset( $_SERVER['SERVER_NAME'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_NAME'] ) ) : ''; - - if ( '80' !== $server_port ) { - $page_url .= $server_name . ':' . $server_port . $server_uri; - - } else { - $page_url .= $server_name . $server_uri; - } - - if ( function_exists( 'apply_filters' ) ) { - $page_url = apply_filters( 'mo_curl_page_url', $page_url ); - } - - // Validate and escape the URL before returning. - $validated_url = filter_var( $page_url, FILTER_VALIDATE_URL ); - return $validated_url ? esc_url_raw( $validated_url ) : esc_url_raw( $page_url ); - } - - /** - * Validates a file path against a base directory to prevent LFI/RFI attacks. - * This function does not include/require the file. It only returns whether - * the given file path is valid and readable within the allowed base directory. - * - * @param string $file_path The file path to validate. - * @param string $base_dir The base directory to restrict file access to. - * @return bool True if the file exists, is readable, and is within base dir; otherwise false. - */ - public static function mo_require_file( $file_path, $base_dir ) { - if ( empty( $file_path ) || empty( $base_dir ) ) { - return false; - } - - $real_base_dir = realpath( $base_dir ); - $real_file_path = realpath( $file_path ); - - if ( false === $real_file_path || false === $real_base_dir ) { - return false; - } - - if ( ! is_dir( $real_base_dir ) || ! is_file( $real_file_path ) || ! is_readable( $real_file_path ) ) { - return false; - } - - // Normalize separators and ensure base dir boundary using trailing separator match. - $base_norm = rtrim( str_replace( '\\', '/', $real_base_dir ), '/' ) . '/'; - $file_norm = str_replace( '\\', '/', $real_file_path ); - if ( strncmp( $file_norm, $base_norm, strlen( $base_norm ) ) !== 0 ) { - return false; - } - - return true; - } - - /** - * Checks if the current user has the required capabilities for admin access. - * This is a centralized function to ensure consistent capability checks across the plugin. - * - * @param array $capabilities Array of capabilities to check. User needs at least one. Default: ['manage_options']. - * @param bool $require_admin Whether to also require is_admin() context. Default: false. - * @return bool True if user has required capabilities (and admin context if required), false otherwise. - */ - public static function mo_check_admin_capability( $capabilities = array( 'manage_options' ), $require_admin = false ) { - // Validate input. - if ( ! is_array( $capabilities ) || empty( $capabilities ) ) { - $capabilities = array( 'manage_options' ); - } - - // Check if required functions exist. - if ( ! function_exists( 'current_user_can' ) ) { - return false; - } - - // If admin context is required, check it first. - if ( $require_admin ) { - if ( ! function_exists( 'is_admin' ) || ! is_admin() ) { - return false; - } - } - - // Check if user has at least one of the required capabilities. - foreach ( $capabilities as $capability ) { - if ( is_string( $capability ) && current_user_can( $capability ) ) { - return true; - } - } - - return false; - } - - /** - * The function retrieves the domain part of the email - * - * @param string $email - the email whose domain has to be validated. - * - * @return bool|string - */ - public static function get_domain( $email ) { - $domain_name = substr( strrchr( $email, '@' ), 1 ); - return $domain_name; - } - - - /** - * This function validates the phone number format. Makes sure that country code - * is appended to the phone number. Return True or false. - * - * @param string $phone - the phone number to be validated. - * - * @return false|int - */ - public static function validate_phone_number( $phone ) { - $phone = self::process_phone_number( $phone ); - - // Basic format validation using regex patterns. - if ( ! preg_match( MoConstants::PATTERN_PHONE, $phone ) ) { - return false; - } - - // Get country code from phone number. - $country_code = self::get_country_code( $phone ); - if ( ! $country_code ) { - return false; - } - - // Extract national significant number (without country code). - $nsn = substr( $phone, strlen( $country_code ) ); - $nsn_length = strlen( $nsn ); - if ( 0 === $nsn_length ) { - return false; - } - $first_digit = substr( $nsn, 0, 1 ); - - // Find the best matching country for this country code using prefixes; keep first match as fallback. - $country_list = CountryList::get_countrycode_list(); - $country_data = null; - $fallback = null; - foreach ( $country_list as $cand ) { - if ( ! isset( $cand['countryCode'] ) || $cand['countryCode'] !== $country_code ) { - continue; - } - if ( null === $fallback ) { - $fallback = $cand; - } - if ( isset( $cand['prefixes'] ) && is_array( $cand['prefixes'] ) && in_array( $first_digit, $cand['prefixes'], true ) ) { - $country_data = $cand; - break; - } - } - if ( ! $country_data && $fallback ) { - $country_data = $fallback; - } - if ( ! $country_data ) { - return false; - } - - // Validate length using min/max from metadata if present; else default 7-15 digits. - $min_len = ( isset( $country_data['minLength'] ) && is_numeric( $country_data['minLength'] ) ) ? (int) $country_data['minLength'] : 7; - $max_len = ( isset( $country_data['maxLength'] ) && is_numeric( $country_data['maxLength'] ) ) ? (int) $country_data['maxLength'] : 15; - if ( $nsn_length < $min_len || $nsn_length > $max_len ) { - return false; - } - - // Validate allowed first-digit prefixes if provided. - if ( isset( $country_data['prefixes'] ) && is_array( $country_data['prefixes'] ) && ! empty( $country_data['prefixes'] ) ) { - if ( ! in_array( $first_digit, $country_data['prefixes'], true ) ) { - return false; - } - } - - return true; - } - - - /** - * This function validates the phone number format and checks if it has country code appended. - * Return True or false. - * - * @param string $phone - the phone number to be checked. - * - * @return bool - */ - public static function is_country_code_appended( $phone ) { - return preg_match( MoConstants::PATTERN_COUNTRY_CODE, $phone, $matches ) ? true : false; - } - - /** - * Process the phone number, return the country code appended to the phone number. If - * country code is not appended then return the default country code if set any by the - * admin. - * - * @param string $phone - the phone number to be processed. - * - * @return mixed - */ - public static function get_country_code( $phone ) { - if ( ! $phone ) { - return; - } - $phone = preg_replace( MoConstants::PATTERN_SPACES_HYPEN, '', ltrim( trim( $phone ), '0' ) ); - $default_country_code = CountryList::get_default_countrycode(); - $country_list = CountryList::get_countrycode_list(); - if ( ! self::is_country_code_appended( $phone ) ) { - return $default_country_code; - } - usort( - $country_list, - function ( $country_a, $country_b ) { - return strlen( $country_b['countryCode'] ) - strlen( $country_a['countryCode'] ); - } - ); - foreach ( $country_list as $country_data ) { - if ( strpos( $phone, $country_data['countryCode'] ) === 0 ) { - return $country_data['countryCode']; - } - } - } - - /** - * Process the phone number. Check if country code is appended to the phone number. If - * country code is not appended then add the default country code if set any by the - * admin. - * - * @param string $phone - the phone number to be processed. - * - * @return mixed - */ - public static function process_phone_number( $phone ) { - if ( ! $phone ) { - return; - } - $phone = preg_replace( MoConstants::PATTERN_SPACES_HYPEN, '', ltrim( trim( $phone ), '0' ) ); - $default_country_code = CountryList::get_default_countrycode(); - $phone = ! isset( $default_country_code ) || self::is_country_code_appended( $phone ) ? $phone : $default_country_code . $phone; - return apply_filters( 'mo_process_phone', $phone ); - } - - - /** - * Checks if user has completed his registration in miniOrange. - * - * @return int - */ - public static function micr() { - $email = get_mo_option( 'admin_email' ); - $customer_key = get_mo_option( 'admin_customer_key' ); - if ( ! $email || ! $customer_key || ! is_numeric( trim( $customer_key ) ) ) { - return 0; - } else { - return 1; - } - } - - /** - * Checks the class for license library and returns bool by checking if the license is expired. - * - * @return bool|array - */ - public static function mllc() { - $is_free_plugin = strcmp( MOV_TYPE, 'MiniOrangeGateway' ) === 0; - return ( class_exists( MoConstants::LICENCE_LIBRARY, false ) || ( ! $is_free_plugin ) ) ? Mo_License_Service::is_license_expired() : array( 'STATUS' => false ); - } - /** - * Function generates a random alphanumeric value and returns it. - * - * @return string - */ - public static function rand() { - $length = wp_rand( 0, 15 ); - $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; - $random_string = ''; - for ( $i = 0; $i < $length; $i++ ) { - $random_string .= $characters[ wp_rand( 0, strlen( $characters ) - 1 ) ]; - } - return $random_string; - } - - - /** - * Checks if user has upgraded to one of the plans. - * - * @return int - */ - public static function micv() { - $email = get_mo_option( 'admin_email' ); - $customer_key = get_mo_option( 'admin_customer_key' ); - $check_ln = get_mo_option( 'check_ln' ); - if ( ! $email || ! $customer_key || ! is_numeric( trim( $customer_key ) ) ) { - return 0; - } else { - return $check_ln ? $check_ln : 0; - } - } - - /** - * This function checks the license of the customer. Updates the license plan, - * sms and email remaining values in the database if user has upgraded. - * - * @param string $show_message - show message or not. - * @param string $customer_key - customerKey of the admin. - * @param string $api_key - apiKey of the admin. - * @return void - */ - public static function handle_mo_check_ln( $show_message, $customer_key, $api_key ) { - $msg = MoMessages::FREE_PLAN_MSG; - $plan = array(); - - $gateway = GatewayFunctions::instance(); - $content = json_decode( MocURLCall::check_customer_ln( $customer_key, $api_key, $gateway->get_application_name(), 'PREMIUM' ), true ); - if ( isset( $content['status'] ) && strcasecmp( $content['status'], 'SUCCESS' ) === 0 ) { - - $email_remaining = isset( $content['emailRemaining'] ) ? $content['emailRemaining'] : 0; - $sms_remaining = isset( $content['smsRemaining'] ) ? $content['smsRemaining'] : 0; - $license_plan = isset( $content['licensePlan'] ) ? $content['licensePlan'] : ''; - - if ( self::sanitize_check( 'licensePlan', $content ) ) { - if ( 0 === strcmp( MOV_TYPE, 'MiniOrangeGateway' ) || 0 === strcmp( MOV_TYPE, 'EnterpriseGatewayWithAddons' ) ) { - $msg = MoMessages::REMAINING_TRANSACTION_MSG; - $plan = array( - 'plan' => $license_plan, - 'sms' => $sms_remaining, - 'email' => $email_remaining, - ); - - } else { - $msg = MoMessages::UPGRADE_MSG; - $plan = array( 'plan' => $license_plan ); - } - update_mo_option( 'check_ln', $license_plan ); - } - update_mo_option( 'customer_license_plan', $license_plan ); - update_mo_option( 'email_transactions_remaining', $email_remaining ); - update_mo_option( 'phone_transactions_remaining', $sms_remaining ); - } else { - $content = json_decode( MocURLCall::check_customer_ln( $customer_key, $api_key, 'wp_email_verification_intranet', 'PREMIUM' ), true ); - $email_remaining = isset( $content['emailRemaining'] ) ? $content['emailRemaining'] : 0; - $sms_remaining = isset( $content['smsRemaining'] ) ? $content['smsRemaining'] : 0; - $license_plan = isset( $content['licensePlan'] ) ? $content['licensePlan'] : ''; - update_mo_option( 'customer_license_plan', $license_plan ); - update_mo_option( 'email_transactions_remaining', $email_remaining ); - update_mo_option( 'phone_transactions_remaining', $sms_remaining ); - - if ( self::sanitize_check( 'licensePlan', $content ) ) { - $msg = MoMessages::INSTALL_PREMIUM_PLUGIN; - } - } - - if ( isset( $content['status'] ) && strcasecmp( $content['status'], 'FAILED' ) === 0 ) { - $content = json_decode( MocURLCall::check_customer_ln( $customer_key, $api_key, '' ), true ); - $email_remaining = isset( $content['emailRemaining'] ) ? $content['emailRemaining'] : 0; - $sms_remaining = isset( $content['smsRemaining'] ) ? $content['smsRemaining'] : 0; - $license_plan = isset( $content['licenseType'] ) ? $content['licenseType'] : ''; - update_mo_option( 'customer_license_plan', $license_plan ); - update_mo_option( 'email_transactions_remaining', $email_remaining ); - update_mo_option( 'phone_transactions_remaining', $sms_remaining ); - } - if ( isset( $content['licenseExpiry'] ) && strcasecmp( $content['status'], 'SUCCESS' ) === 0 ) { - if ( class_exists( MoConstants::LICENCE_LIBRARY, false ) ) { - Mo_License_Service::update_license_expiry( $content['licenseExpiry'] ); - } - } - - if ( $show_message ) { - do_action( 'mo_registration_show_message', MoMessages::showMessage( $msg, $plan ), 'SUCCESS' ); - } - } - - - /** - * Initialize the form session indicating that the OTP Verification for the - * form has started. - * - * @param string $form - form for which session is being initialized / session constant name. - */ - public static function initialize_transaction( $form ) { - if ( empty( $form ) ) { - return; - } - - MoPHPSessions::bootstrap(); - MoPHPSessions::check_session(); - - $reflect = new ReflectionClass( FormSessionVars::class ); - foreach ( $reflect->getConstants() as $key => $value ) { - // Don't unset the current form session variable as we're about to initialize it. - if ( $value !== $form ) { - MoPHPSessions::unset_session( $value ); - } - } - - SessionUtils::initialize_form( $form ); - } - - - /** - * Returns the invalid OTP message. This function checks if admin has set an - * invalid otp message in the settings. If so then that is returned instead of the default. - * - * @return string - */ - public static function get_invalid_otp_method() { - return get_mo_option( 'invalid_message', 'mo_otp_' ) ? get_mo_option( 'invalid_message', 'mo_otp_' ) - : MoMessages::showMessage( MoMessages::INVALID_OTP ); - } - - - /** - * Returns TRUE or FALSE depending on if the POLYLANG plugin is active. - * This is used to check if the translation should use the polylang - * function or the default local translation. - * - * @return boolean - */ - public static function is_polylang_installed() { - return function_exists( 'pll__' ) && function_exists( 'pll_register_string' ); - } - - /** - * Take an array of string having the keyword to replace - * and the keyword to be replaced. This is used to modify - * the SMS templates that the user might have saved in the - * settings or the default ones by the plugin. - * - * @param array $replace The array containing search and replace keywords. - * @param string $input_string Entire string to be modified. - * - * @return mixed - */ - public static function replace_string( array $replace, $input_string ) { - foreach ( $replace as $key => $value ) { - $input_string = str_replace( '{' . $key . '}', $value, $input_string ); - } - - return $input_string; - } - - /** - * Returns a stdClass Object with status Success as a - * temporary result when TEST_MODE is on - * - * @return stdClass - */ - private static function test_result() { - $temp = new stdClass(); - $temp->status = MO_FAIL_MODE ? 'ERROR' : 'SUCCESS'; - return $temp; - } - - /** - * Checks if the whatsapp notifications and presonal business account is enabled - * - * @return bool - */ - public static function mo_is_whatsapp_notif_enabled() { - return get_mo_option( 'mo_whatsapp_enable' ) - && get_mo_option( 'mo_whatsapp_notification_enable' ) - && get_mo_option( 'mo_whatsapp_type' ) === 'bussiness_whatsapp'; - } - - - /** - * Send the notification to the number provided and - * process the response to check if the message was sent - * successfully or not. Return TRUE or FALSE based on the - * API call response. - * - * @param string $number the number to be sent. - * @param string $msg the message to be sent. - * @param string $notification_type the specific type of notification (e.g., 'NEW_ACCOUNT', 'ORDER_STATUS'). - * - * @return bool - */ - public static function send_phone_notif( $number, $msg, $notification_type = 'NOTIFICATION' ) { - - $api_call_result = function ( $number, $msg ) { - return json_decode( MocURLCall::send_notif( new NotificationSettings( $number, $msg ) ) ); - }; - - $mle = self::mllc(); - if ( $mle['STATUS'] ) { - return false; - } - $number = self::process_phone_number( $number ); - $msg = self::replace_string( array( 'phone' => str_replace( '+', '', '%2B' . $number ) ), $msg ); - $content = MO_TEST_MODE ? self::test_result() : $api_call_result( $number, $msg ); - $notif_status = strcasecmp( $content->status, 'SUCCESS' ) === 0 ? 'SMS_NOTIF_SENT' : 'SMS_NOTIF_FAILED'; - apply_filters( 'mo_start_reporting', null, $number, $number, $notification_type . '_PHONE_NOTIF', $msg, $notif_status ); - return strcasecmp( $content->status, 'SUCCESS' ) === 0 ? true : false; - } - - /** - * Send the notification to the number provided and - * process the response to check if the message was sent - * successfully or not. Return TRUE or FALSE based on the - * API call response. - * - * @param string $number the number to be sent. - * @param string $template_name the template name. - * @param string $sms_tags the tags used in sms template. - * @param string $notification_type the specific type of notification (e.g., 'NEW_ACCOUNT', 'ORDER_STATUS'). - * - * @return bool - */ - public static function mo_send_whatsapp_notif( $number, $template_name, $sms_tags, $notification_type = 'WHATSAPP_NOTIFICATION' ) { - $api_call_result = function ( $number, $data ) { - return apply_filters( 'mo_wa_send_otp_token', 'WHATSAPP_NOTIFICATION', null, null, $number, $data ); - }; - - $data = array( - 'template_name' => $template_name, - 'sms_tags' => $sms_tags, - ); - $number = self::process_phone_number( $number ); - $content = MO_TEST_MODE ? self::test_result() : $api_call_result( $number, $data ); - $notif_status = strcasecmp( $content->status, 'SUCCESS' ) === 0 ? 'SMS_NOTIF_SENT' : 'SMS_NOTIF_FAILED'; - apply_filters( 'mo_start_reporting', null, $number, $number, $notification_type . '_WHATSAPP_NOTIF', $template_name, $notif_status ); - return strcasecmp( $content->status, 'SUCCESS' ) === 0 ? true : false; - } - - - /** - * Send the notification to the email provided and - * process the response to check if the message was sent - * successfully or not. Return TRUE or FALSE based on the - * API call response. - * - * @param string $from_email The From Email. - * @param string $from_name The From Name. - * @param string $to_email The email to send message to. - * @param string $subject The subject of the email. - * @param string $message The message to be sent. - * - * @return bool - */ - public static function send_email_notif( $from_email, $from_name, $to_email, $subject, $message ) { - $api_call_result = function ( $from_email, $from_name, $to_email, $subject, $message ) { - $notification_settings = new NotificationSettings(); - $notification_settings->create_email_notification_settings( $from_email, $from_name, $to_email, $subject, $message ); - return json_decode( MocURLCall::send_notif( $notification_settings ) ); - }; - $content = MO_TEST_MODE ? self::test_result() : $api_call_result( $from_email, $from_name, $to_email, $subject, $message ); - return strcasecmp( $content->status, 'SUCCESS' ) === 0 ? true : false; - } - /** - * Check if there is an existing value in the array/buffer and return the value - * that exists against that key otherwise return false. - * <p></p> - * The function also makes sure to sanitize the values being fetched. - * <p></p> - * If the buffer to fetch the value from is not an array then return buffer as it is. - * - * @param string $key the key to check against. - * @param string|array $buffer the post/get or array. - * @return string|bool|array - */ - public static function sanitize_check( $key, $buffer ) { - if ( ! isset( $buffer[ $key ] ) ) { - return false; - } - if ( is_array( $buffer[ $key ] ) ) { - return self::mo_sanitize_array( $buffer[ $key ] ); - } else { - return sanitize_text_field( wp_unslash( $buffer[ $key ] ) ); - } - } - - /** - * Checks if user has upgraded to the on-prem plugin - */ - public static function mclv() { - $gateway = GatewayFunctions::instance(); - return $gateway->mclv(); - } - - - /**Checks if the current plugin is Custom Gateway Plugin - */ - public static function is_gateway_config() { - $gateway = GatewayFunctions::instance(); - return $gateway->is_gateway_config(); - } - - /** - * Checks if the current plugin is MiniOrangeGateway Plugin - * - * @return bool - */ - public static function is_mg() { - $gateway = GatewayFunctions::instance(); - return $gateway->is_mg(); - } - - - /** - * This function checks if all conditions to save the form settings - * are true. This checks if the user saving the form settings is an admin, - * has registered with miniorange and the the form post has an option value - * mo_customer_validation_settings - * - * @param string $key_val the key to check against (typically 'mo_customer_validation_settings'). - * @param string $form_option_key Optional POST key that must be present to proceed (form-specific). - * - * @return bool - */ - public static function are_form_options_being_saved( $key_val, $form_option_key = '' ) { - if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['_wpnonce'] ) ), 'mo_admin_actions' ) ) { - return false; - } - if ( ! current_user_can( 'manage_options' ) ) { - return false; - } - if ( ! self::mclv() ) { - return false; - } - - $option_value = isset( $_POST['option'] ) ? sanitize_text_field( wp_unslash( $_POST['option'] ) ) : ''; - if ( empty( $option_value ) || $option_value !== $key_val ) { - return false; - } - if ( ! empty( $form_option_key ) ) { - $prefixed_key = 'mo_customer_validation_' . $form_option_key; - $form_key_exists = isset( $_POST[ $prefixed_key ] ) || isset( $_POST[ $form_option_key ] ); - if ( $form_key_exists ) { - return true; - } - // Form checkbox doesn't exist - form might be disabled, but we're in form settings save context. - $form_base = str_replace( '_enable', '', $form_option_key ); - $parts = explode( '_', $form_base ); - $form_id = $parts[0]; - $form_data_key = str_replace( '_enable', '_form', $form_option_key ); - if ( isset( $_POST[ $form_data_key ] ) ) { - return true; - } - foreach ( $_POST as $key => $value ) { - $key_to_check = $key; - if ( strpos( $key, 'mo_customer_validation_' ) === 0 ) { - $key_to_check = str_replace( 'mo_customer_validation_', '', $key ); - } elseif ( strpos( $key, 'mo_' ) === 0 ) { - $key_to_check = str_replace( 'mo_', '', $key ); - } - if ( strpos( $key_to_check, $form_id . '_' ) === 0 || strpos( $key_to_check, $form_base ) === 0 ) { - return true; - } - } - // Return false to prevent unnecessary processing. - return false; - } - return true; - } - - /** - * Update SMS Email transaction in DataBase - * - * @param string $response Response form Gateway. - * @param string $type OTP Type email or phone. - */ - public static function mo_update_sms_email_transations( $response, $type ) { - $content = json_decode( $response ); - if ( strcasecmp( $content->status, 'SUCCESS' ) === 0 ) { - $option_type = ( VerificationType::PHONE === $type ) ? 'phone_transactions_remaining' : 'email_transactions_remaining'; - $remaining_txn = get_mo_option( $option_type ); - if ( $remaining_txn > 0 ) { - update_mo_option( $option_type, $remaining_txn - 1 ); - } - } - } - - /** - * Update WhatsApp transaction in DataBase - * - * @param string $response Resposne form Gateway. - */ - public static function mo_update_whatsapp_transations( $response ) { - $content = json_decode( $response ); - if ( isset( $content->status ) && strcasecmp( $content->status, 'SUCCESS' ) === 0 ) { - $remaining_txn = get_mo_option( 'whatsapp_transactions_remaining', 'mowp_customer_validation_' ); - if ( $remaining_txn > 0 ) { - update_mo_option( 'whatsapp_transactions_remaining', $remaining_txn - 1, 'mowp_customer_validation_' ); - } - } - } - - /** - * Checks if the customer is registered or not and shows a message on the page - * to the user so that they can register or login themselves to use the plugin. - */ - public static function is_addon_activated() { - if ( self::micr() && self::mclv() ) { - return; - } - $tab_details = TabDetails::instance(); - $server_uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; - $registration_url = add_query_arg( - array( 'page' => $tab_details->tab_details[ Tabs::ACCOUNT ]->menu_slug ), - remove_query_arg( 'addon', $server_uri ) - ); - echo '<div style="display:block;margin-top:10px;color:red;background-color:rgba(251, 232, 0, 0.15); - padding:5px;border:solid 1px rgba(255, 0, 9, 0.36);"> - <a href="' . esc_url( $registration_url ) . '">' . esc_html( __( 'Validate your purchase', 'miniorange-otp-verification' ) ) . '</a> - ' . esc_html( __( ' to enable the Add On', 'miniorange-otp-verification' ) ) . '</div>'; - } - - /** - * Check if the phone number is empty and return error. - * - * @param string $phone_number phone number of the user. - */ - public static function check_if_phone_exist( $phone_number ) { - if ( empty( $phone_number ) ) { - wp_send_json( self::create_json( MoMessages::showMessage( MoMessages::PHONE_NOT_FOUND ), MoConstants::ERROR_JSON_TYPE ) ); - } - } - - /** - * Checks the version of the plugin active with the mentioned name. - * - * @param string $plugin_name - Plugin Name. - * @param integer $sequence - index of the version digit to get. - * @return integer Version number. - */ - public static function get_active_plugin_version( $plugin_name, $sequence = 0 ) { - if ( ! function_exists( 'get_plugins' ) ) { - require_once ABSPATH . 'wp-admin/includes/plugin.php'; - } - $all_plugins = get_plugins(); - $active_plugin = get_mo_option( 'active_plugins', '' ); - if ( ! is_array( $active_plugin ) ) { - $active_plugin = array(); - } - foreach ( $all_plugins as $key => $value ) { - if ( isset( $value['Name'] ) && strcasecmp( $value['Name'], $plugin_name ) === 0 ) { - if ( in_array( $key, $active_plugin, true ) ) { - // Make sure the version is set and has the requested sequence. - if ( isset( $value['Version'] ) && isset( $value['Version'][ $sequence ] ) ) { - return (int) $value['Version'][ $sequence ]; - } - } - } - } - return null; - } - - /** - * Encrypts a plaintext password using AES-256-CBC encryption. - * - * @param string $plaintext_password The plain text password to encrypt. - * @return string Hex-encoded encrypted string. - */ - public static function encrypt_password( $plaintext_password ) { - if ( empty( $plaintext_password ) ) { - return ''; - } - $encryption_key = hash( 'sha256', wp_salt( 'auth' ), true ); - $iv = substr( hash( 'sha256', 'otp-plugin-password-iv' ), 0, 16 ); - $encrypted = openssl_encrypt( - $plaintext_password, - 'AES-256-CBC', - $encryption_key, - 0, - $iv - ); - if ( false === $encrypted ) { - return ''; - } - return bin2hex( $encrypted ); - } - - /** - * Decrypts an AES-256-CBC encrypted password back to plain text. - * - * @param string $encrypted_password The hex-encoded encrypted password. - * @return string|false Decrypted plain text password, or false on failure. - */ - public static function decrypt_password( $encrypted_password ) { - if ( empty( $encrypted_password ) ) { - return ''; - } - $encryption_key = hash( 'sha256', wp_salt( 'auth' ), true ); - $iv = substr( hash( 'sha256', 'otp-plugin-password-iv' ), 0, 16 ); - $encrypted_data = hex2bin( $encrypted_password ); - if ( false === $encrypted_data ) { - return ''; - } - $decrypted = openssl_decrypt( - $encrypted_data, - 'AES-256-CBC', - $encryption_key, - 0, - $iv - ); - return false === $decrypted ? '' : $decrypted; - } - - /** - * Get the current user's IP address. - * - * @return string - IP address - */ - public static function get_current_ip_address() { - $ip_sources = array( - 'REMOTE_ADDR' => array( - 'trust' => true, - 'risk' => 'low', - ), - 'HTTP_X_FORWARDED_FOR' => array( - 'trust' => false, - 'risk' => 'medium', - ), - 'HTTP_X_REAL_IP' => array( - 'trust' => false, - 'risk' => 'low', - ), - 'HTTP_CF_CONNECTING_IP' => array( - 'trust' => false, - 'risk' => 'low', - ), - 'HTTP_CLIENT_IP' => array( - 'trust' => false, - 'risk' => 'high', - ), - ); - - $found_ips = array(); - $suspicious_ips = array(); - - foreach ( $ip_sources as $source => $metadata ) { - if ( empty( $_SERVER[ $source ] ) ) { - continue; - } - - $raw_value = sanitize_text_field( wp_unslash( $_SERVER[ $source ] ) ); - $ip = self::extract_first_valid_ip( $raw_value ); - - if ( $ip && self::is_valid_ip( $ip ) ) { - $found_ips[] = array( - 'ip' => $ip, - 'trust' => $metadata['trust'], - 'risk' => $metadata['risk'], - ); - } else { - $suspicious_ips[] = array( - 'ip' => $ip ? $ip : 'invalid_format', - 'source' => $source, - 'raw_value' => $raw_value, - 'reason' => $ip ? 'contains_attack_patterns' : 'invalid_ip_format', - ); - } - } - - if ( ! empty( $found_ips ) ) { - usort( - $found_ips, - function ( $a, $b ) { - if ( $a['trust'] === $b['trust'] ) { - $risk_order = array( - 'low' => 0, - 'medium' => 1, - 'high' => 2, - ); - return $risk_order[ $a['risk'] ] - $risk_order[ $b['risk'] ]; - } - return $b['trust'] - $a['trust']; - } - ); - - $best_ip = $found_ips[0]; - $risk_markers = array( - 'high' => ' (high_risk_proxy)', - 'medium' => ' (medium_risk_proxy)', - ); - - return $best_ip['ip'] . ( $risk_markers[ $best_ip['risk'] ] ?? '' ); - } - - if ( isset( $_SERVER['REMOTE_ADDR'] ) ) { - $fallback_ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ); - return self::is_valid_ip( $fallback_ip ) ? $fallback_ip : $fallback_ip . ' (suspicious_format)'; - } - - if ( ! empty( $suspicious_ips ) ) { - self::log_suspicious_ip_activity( $suspicious_ips ); - $first_suspicious = $suspicious_ips[0]; - return 'invalid_format' !== $first_suspicious['ip'] ? $first_suspicious['ip'] : 'Unknown'; - } - - return 'Unknown'; - } - - /** - * Get the first matching page ID by its title using a non-deprecated approach. - * - * @param string $page_title Page title to search for. - * @param string $post_status Page status to include (default 'all'). - * @return int|string Page ID if found; empty string if not found or title blank. - */ - public static function mo_get_page_id_by_title( $page_title, $post_status = 'all' ) { - if ( self::is_blank( $page_title ) ) { - return ''; - } - - $pages = get_posts( - array( - 'post_type' => 'page', - 'title' => $page_title, - 'post_status' => $post_status, - 'numberposts' => 1, - 'suppress_filters' => false, - ) - ); - - return ( ! empty( $pages ) && isset( $pages[0]->ID ) ) ? (int) $pages[0]->ID : ''; - } - - /** - * Get the permalink for the first matching page title, or a default if not found. - * - * @param string $page_title Page title to search for. - * @param string $default_url Default URL to return if not found (default home_url('/')). - * @param string $post_status Page status to include (default 'all'). - * @return string Resolved permalink or default. - */ - public static function mo_get_permalink_by_page_title( $page_title, $default_url = '', $post_status = 'all' ) { - $default = $default_url ? $default_url : home_url( '/' ); - $page_id = self::mo_get_page_id_by_title( $page_title, $post_status ); - $redirect_url = $page_id ? get_permalink( $page_id ) : $default; - return esc_url_raw( $redirect_url ); - } - - /** - * Check for suspicious IP patterns (disabled for now). - * - * @param string $ip - IP address to validate. - * @return bool - True if valid and safe - */ - private static function is_valid_ip( $ip ) { - if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6 ) ) { - return false; - } - if ( self::contains_attack_patterns( $ip ) ) { - return false; - } - return true; - } - - /** - * Check for attack patterns in IP string. - * - * @param string $ip - IP address to check. - * @return bool - True if contains attack patterns - */ - private static function contains_attack_patterns( $ip ) { - $suspicious_chars = array( '<', '>', '"', "'", '\\', '/', '&', ';', '(', ')' ); - foreach ( $suspicious_chars as $char ) { - if ( strpos( $ip, $char ) !== false ) { - return true; - } - } - return false; - } - - /** - * Get relevant server variables for security logging. - * - * @return array - Sanitized server variables - */ - private static function get_relevant_server_vars() { - $relevant_vars = array( - 'REMOTE_ADDR', - 'HTTP_X_FORWARDED_FOR', - 'HTTP_X_REAL_IP', - 'HTTP_CF_CONNECTING_IP', - 'HTTP_CLIENT_IP', - 'HTTP_USER_AGENT', - 'REQUEST_URI', - 'REQUEST_METHOD', - 'HTTP_REFERER', - ); - - $server_data = array(); - foreach ( $relevant_vars as $var ) { - if ( isset( $_SERVER[ $var ] ) ) { - $server_data[ $var ] = sanitize_text_field( wp_unslash( $_SERVER[ $var ] ) ); - } - } - - return $server_data; - } - - /** - * Validate IP address with basic security checks. - * - * @param string $ip_string - Comma-separated IP addresses. - * @return string|false - First valid IP or false - */ - private static function extract_first_valid_ip( $ip_string ) { - $ips = explode( ',', $ip_string ); - foreach ( $ips as $ip ) { - $ip = trim( $ip ); - - if ( empty( $ip ) || ! is_string( $ip ) ) { - continue; - } - - if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6 ) ) { - return $ip; - } - } - return false; - } - - /** - * Log suspicious IP activity for security analysis. - * - * @param array $suspicious_ips - Array of suspicious IP data. - */ - private static function log_suspicious_ip_activity( $suspicious_ips ) { - $log_data = array( - 'timestamp' => current_time( 'mysql' ), - 'user_agent' => sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown' ) ), - 'request_uri' => sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? 'Unknown' ) ), - 'suspicious_ips' => $suspicious_ips, - 'all_server_vars' => self::get_relevant_server_vars(), - ); - - $security_logs = get_mo_option( 'mo_otp_security_logs', array() ); - $security_logs[] = $log_data; - - if ( count( $security_logs ) > 100 ) { - $security_logs = array_slice( $security_logs, -100 ); - } - - update_mo_option( 'mo_otp_security_logs', $security_logs ); - } - - /** - * Get current page parameter value from URL query string. - * This function safely retrieves GET parameters without triggering PHPCS nonce verification warnings. - * It parses the REQUEST_URI to extract query parameters, which is safe for routing/display purposes. - * - * @param string $parameter_name The name of the parameter to retrieve. - * @param string $default_value Default value to return if parameter is not found. - * @return string The parameter value or default value. - */ - public static function get_current_page_parameter_value( $parameter_name, $default_value = '' ) { - $path = ! empty( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; - - $parameter_value = ''; - - // Parse the URL to get the query string. - $query_str = wp_parse_url( $path, PHP_URL_QUERY ); - - // Parse the query string into an array. - if ( $query_str ) { - parse_str( $query_str, $query_params ); - - // Get the parameter value if it exists. - if ( ! empty( $query_params[ $parameter_name ] ) ) { - $parameter_value = sanitize_text_field( $query_params[ $parameter_name ] ); - } - unset( $query_params ); - } - - return ! empty( $parameter_value ) ? $parameter_value : $default_value; - } - } -} +<?php +/** + * Load administrator changes for MoUtility + * + * @package miniorange-otp-verification/helper + */ + +namespace OTP\Helper; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +use OTP\Objects\NotificationSettings; +use OTP\Objects\TabDetails; +use OTP\Objects\Tabs; +use OTP\Objects\VerificationType; +use ReflectionClass; +use ReflectionException; +use stdClass; +use OTP\LicenseLibrary\Mo_License_Service; +use OTP\Helper\MoConstants; +use OTP\Helper\CountryList; + +/** + * This is the main Utility class of the plugin. + * Lists down all the necessary common utility + * functions being used in the plugin. + */ +if ( ! class_exists( 'MoUtility' ) ) { + /** + * MoUtility class + */ + class MoUtility { + + + /** + * Checking Script tags + * + * @param string $template checking script tag. + * @return string + */ + public static function check_for_script_tags( $template ) { + return preg_match( '/<script>/', $template, $match ); + } + + /** + * Sanitizing array + * + * @param array $data data array to be sanitized. + * @return array + */ + public static function mo_sanitize_array( $data ) { + $sanitized_data = array(); + foreach ( $data as $key => $value ) { + $key = sanitize_key( $key ); + if ( empty( $key ) ) { + continue; + } + if ( is_array( $value ) ) { + $sanitized_data[ $key ] = self::mo_sanitize_array( $value ); + } else { + $sanitized_data[ $key ] = sanitize_text_field( wp_unslash( $value ) ); + } + } + return $sanitized_data; + } + + /** + * MoInternal Function + * + * @return array + */ + public static function mo_allow_html_array() { + $allowed_tags = array( + 'a' => array( + 'style' => array(), + 'class' => array(), + 'href' => array(), + 'rel' => array(), + 'title' => array(), + 'hidden' => array(), + 'target' => array(), + 'onclick' => array(), + ), + 'b' => array( + 'style' => array(), + 'class' => array(), + 'id' => array(), + ), + 'blockquote' => array( + 'cite' => array(), + ), + 'code' => array(), + 'del' => array( + 'datetime' => array(), + 'title' => array(), + ), + 'div' => array( + 'name' => array(), + 'dir' => array(), + 'id' => array(), + 'class' => array(), + 'title' => array(), + 'style' => array(), + 'hidden' => array(), + ), + 'dl' => array(), + 'dt' => array(), + 'em' => array(), + 'h1' => array(), + 'h2' => array(), + 'h3' => array(), + 'h4' => array(), + 'h5' => array(), + 'h6' => array(), + 'hr' => array(), + 'i' => array(), + 'textarea' => array( + 'id' => array(), + 'class' => array(), + 'name' => array(), + 'row' => array(), + 'style' => array(), + 'placeholder' => array(), + 'readonly' => array(), + ), + 'img' => array( + 'alt' => array(), + 'class' => array(), + 'height' => array(), + 'style' => array(), + 'src' => array(), + 'width' => array(), + 'href' => array(), + 'hidden' => array(), + ), + 'link' => array( + 'rel' => array(), + 'type' => array(), + 'href' => array(), + 'hidden' => array(), + ), + 'li' => array( + 'class' => array(), + 'hidden' => array(), + ), + 'ol' => array( + 'class' => array(), + ), + 'p' => array( + 'class' => array(), + 'hidden' => array(), + 'style' => array(), + ), + 'q' => array( + 'cite' => array(), + 'title' => array(), + ), + 'span' => array( + 'id' => array(), + 'value' => array(), + 'class' => array(), + 'title' => array(), + 'style' => array(), + 'hidden' => array(), + ), + 'strike' => array(), + 'strong' => array(), + 'u' => array(), + 'ul' => array( + 'class' => array(), + 'style' => array(), + ), + 'form' => array( + 'name' => array(), + 'method' => array(), + 'id' => array(), + 'style' => array(), + 'hidden' => array(), + ), + 'table' => array( + 'class' => array(), + 'style' => array(), + 'cellpadding' => array(), + 'cellspacing' => array(), + 'border' => array(), + 'width' => array(), + ), + 'tbody' => array(), + 'button' => array(), + 'tr' => array(), + 'td' => array( + 'class' => array(), + 'style' => array(), + ), + 'input' => array( + 'type' => array(), + 'id' => array(), + 'name' => array(), + 'value' => array(), + 'class' => array(), + 'size ' => array(), + 'tabindex' => array(), + 'hidden' => array(), + 'style' => array(), + 'placeholder' => array(), + 'disabled' => array(), + 'data-next' => array(), + 'data-previous' => array(), + 'maxlength' => array(), + ), + 'br' => array(), + 'title' => array( + 'title' => true, + ), + ); + return $allowed_tags; + } + + /** + * Allowing tags for popup templates + * + * @return array + */ + public static function mo_allow_popup_tags() { + $allowed_tags = array( + 'head' => array(), + 'title' => array(), + 'meta' => array( + 'http-equiv' => array(), + 'content' => array(), + 'name' => array(), + ), + 'html' => array(), + 'body' => array(), + 'style' => array( + 'type' => array(), + ), + 'div' => array( + 'name' => array(), + 'dir' => array(), + 'id' => array(), + 'class' => array(), + 'title' => array(), + 'style' => array(), + 'tabindex' => array(), + 'role' => array(), + 'hidden' => array(), + ), + 'link' => array( + 'href' => array(), + 'target' => array(), + 'rel' => array(), + 'type' => array(), + 'title' => array(), + 'hidden' => array(), + ), + 'input' => array( + 'type' => array(), + 'id' => array(), + 'name' => array(), + 'value' => array(), + 'class' => array(), + 'size ' => array(), + 'tabindex' => array(), + 'hidden' => array(), + ), + 'button' => array( + 'class' => array(), + 'id' => array(), + 'type' => array(), + 'name' => array(), + 'value' => array(), + ), + 'form' => array( + 'name' => array(), + 'method' => array(), + 'action' => array(), + 'id' => array(), + 'class' => array(), + 'hidden' => array(), + ), + 'br' => array(), + 'p' => array( + 'class' => true, + 'style' => true, + 'id' => true, + ), + 'i' => array(), + 'u' => array(), + 'span' => array( + 'id' => array(), + 'value' => array(), + 'class' => array(), + 'title' => array(), + 'style' => array(), + 'hidden' => array(), + ), + 'a' => array( + 'href' => true, + 'target' => true, + 'rel' => true, + 'title' => true, + 'hidden' => true, + 'class' => true, + 'onclick' => true, + ), + 'svg' => array( + 'class' => array(), + 'id' => array(), + 'width' => array(), + 'height' => array(), + 'viewBox' => array(), + 'viewbox' => array(), + 'fill' => array(), + ), + 'circle' => array( + 'id' => array(), + 'cx' => array(), + 'cy' => array(), + 'cz' => array(), + 'r' => array(), + ), + 'g' => array( + 'fill' => array(), + 'id' => array(), + ), + 'path' => array( + 'd' => array(), + 'fill' => array(), + 'stroke' => array(), + 'stroke-width' => array(), + 'stroke-linecap' => array(), + 'stroke-linejoin' => array(), + ), + 'rect' => array( + 'x' => array(), + 'y' => array(), + 'width' => array(), + 'height' => array(), + 'rx' => array(), + 'fill' => array(), + 'stroke' => array(), + 'stroke-width' => array(), + 'stroke-linejoin' => array(), + ), + 'defs' => array(), + 'lineargradient' => array( + 'id' => array(), + 'x1' => array(), + 'x2' => array(), + 'y1' => array(), + 'y2' => array(), + 'gradientunits' => array(), + ), + ); + return $allowed_tags; + } + + /** + * KSES allowlist for rendered OTP popup HTML: popup chrome (forms, svg) plus post-like tags + * in {{MESSAGE}} so links and paragraphs (e.g. admin password hint) survive both inner and outer wp_kses passes. + * + * @return array + */ + public static function mo_popup_html_kses_allowed() { + $popup = self::mo_allow_popup_tags(); + if ( ! function_exists( 'wp_kses_allowed_html' ) ) { + return $popup; + } + $post = wp_kses_allowed_html( 'post' ); + foreach ( $post as $tag => $post_attrs ) { + if ( ! isset( $popup[ $tag ] ) ) { + $popup[ $tag ] = $post_attrs; + continue; + } + $popup[ $tag ] = array_merge( (array) $post_attrs, (array) $popup[ $tag ] ); + } + return $popup; + } + + /** + * MoInternal Function + * + * @return array + */ + public static function mo_allow_svg_array() { + $allowed_tags = array( + 'svg' => array( + 'class' => true, + 'width' => true, + 'height' => true, + 'viewbox' => true, + 'fill' => true, + ), + 'circle' => array( + 'id' => true, + 'cx' => true, + 'cy' => true, + 'cz' => true, + 'r' => true, + 'stroke' => true, + 'stroke-width' => true, + ), + 'g' => array( + 'fill' => true, + 'id' => true, + ), + 'path' => array( + 'd' => true, + 'fill' => true, + 'id' => true, + 'fill-rule' => true, + 'clip-rule' => true, + 'stroke' => true, + 'stroke-width' => true, + 'stroke-linecap' => true, + ), + 'rect' => array( + 'width' => true, + 'height' => true, + 'rx' => true, + 'fill' => true, + ), + 'defs' => array(), + 'lineargradient' => array( + 'id' => true, + 'x1' => true, + 'x2' => true, + 'y1' => true, + 'y2' => true, + 'gradientunits' => true, + ), + 'stop' => array( + 'stop-color' => true, + 'offset' => true, + ), + ); + return $allowed_tags; + } + + + /** + * Masking the Phone Number of User + * + * @param string $phone Phone Number of the user. + */ + public static function mo_mask_phone_number( $phone ) { + $length = strlen( $phone ); + $masked_part = str_repeat( '*', max( 0, $length - 3 ) ); + $last_three = substr( $phone, -3 ); + return $masked_part . $last_three; + } + + + /** + * Masking the Email of User + * + * @param string $email email of the user. + */ + public static function mo_mask_email( $email ) { + $parts = explode( '@', $email ); + if ( count( $parts ) !== 2 ) { + return $email; + } + $username = $parts[0]; + $domain = $parts[1]; + $visible_part = substr( $username, 0, 2 ); + $masked_part = str_repeat( '*', max( 0, strlen( $username ) - 2 ) ); + return $visible_part . $masked_part . '@' . $domain; + } + + /** Process the phone number and get_hidden_phone. + * + * @param string $phone - the phone number to processed. + * + * @return string + */ + public static function get_hidden_phone( $phone ) { + return 'xxxxxxx' . substr( $phone, strlen( $phone ) - 3 ); + } + + + /** + * Process the value being passed and checks if it is empty or null + * + * @param string $value - the value to be checked. + * + * @return bool + */ + public static function is_blank( $value ) { + return ! isset( $value ) || empty( $value ); + } + + /** + * Process the plugin name is being passed and checks if it plugin is active or not + * + * @param string $plugin - the plugin name to be checked. + * + * @return bool + */ + public static function is_plugin_installed( $plugin ) { + if ( ! function_exists( 'is_plugin_active' ) ) { + include_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + return is_plugin_active( $plugin ); + } + + + /** + * Creates and returns the JSON response. + * + * @param string $message - the message. + * @param string $type - the type of result ( success or error ). + * @return array + */ + public static function create_json( $message, $type ) { + return array( + 'message' => $message, + 'result' => $type, + ); + } + /** + * Check for Country Restriction Addon + * + * @param mixed $phone . + * @return bool + */ + public static function check_for_selected_country_addon( $phone ) { + $countriesavail = CountryList::get_countrycode_list(); + $countriesavail = apply_filters( 'selected_countries', $countriesavail ); + + foreach ( $countriesavail as $key => $value ) { + if ( 'All Countries' !== $value['name'] ) { + $country_code = isset( $value['countryCode'] ) ? $value['countryCode'] : ( isset( $value['countrycode'] ) ? $value['countrycode'] : null ); + if ( $country_code && strpos( $phone, $country_code ) !== false ) { + return false; + } + } + } + return true; + } + + /** + * This function checks if cURL is installed on the server. + * + * @return bool + */ + public static function mo_is_curl_installed() { + return in_array( 'curl', get_loaded_extensions(), true ); + } + + + /** + * The function returns the current page URL. + * + * @return string + */ + public static function current_page_url() { + $page_url = 'http'; + + if ( ( isset( $_SERVER['HTTPS'] ) ) && ( sanitize_text_field( wp_unslash( $_SERVER['HTTPS'] ) ) === 'on' ) ) { + $page_url .= 's'; + } + + $page_url .= '://'; + + $server_port = isset( $_SERVER['SERVER_PORT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_PORT'] ) ) : ''; + $server_uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; + $server_name = isset( $_SERVER['SERVER_NAME'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_NAME'] ) ) : ''; + + if ( '80' !== $server_port ) { + $page_url .= $server_name . ':' . $server_port . $server_uri; + + } else { + $page_url .= $server_name . $server_uri; + } + + if ( function_exists( 'apply_filters' ) ) { + $page_url = apply_filters( 'mo_curl_page_url', $page_url ); + } + + // Validate and escape the URL before returning. + $validated_url = filter_var( $page_url, FILTER_VALIDATE_URL ); + return $validated_url ? esc_url_raw( $validated_url ) : esc_url_raw( $page_url ); + } + + /** + * Validates a file path against a base directory to prevent LFI/RFI attacks. + * This function does not include/require the file. It only returns whether + * the given file path is valid and readable within the allowed base directory. + * + * @param string $file_path The file path to validate. + * @param string $base_dir The base directory to restrict file access to. + * @return bool True if the file exists, is readable, and is within base dir; otherwise false. + */ + public static function mo_require_file( $file_path, $base_dir ) { + if ( empty( $file_path ) || empty( $base_dir ) ) { + return false; + } + + $real_base_dir = realpath( $base_dir ); + $real_file_path = realpath( $file_path ); + + if ( false === $real_file_path || false === $real_base_dir ) { + return false; + } + + if ( ! is_dir( $real_base_dir ) || ! is_file( $real_file_path ) || ! is_readable( $real_file_path ) ) { + return false; + } + + // Normalize separators and ensure base dir boundary using trailing separator match. + $base_norm = rtrim( str_replace( '\\', '/', $real_base_dir ), '/' ) . '/'; + $file_norm = str_replace( '\\', '/', $real_file_path ); + if ( strncmp( $file_norm, $base_norm, strlen( $base_norm ) ) !== 0 ) { + return false; + } + + return true; + } + + /** + * Checks if the current user has the required capabilities for admin access. + * This is a centralized function to ensure consistent capability checks across the plugin. + * + * @param array $capabilities Array of capabilities to check. User needs at least one. Default: ['manage_options']. + * @param bool $require_admin Whether to also require is_admin() context. Default: false. + * @return bool True if user has required capabilities (and admin context if required), false otherwise. + */ + public static function mo_check_admin_capability( $capabilities = array( 'manage_options' ), $require_admin = false ) { + // Validate input. + if ( ! is_array( $capabilities ) || empty( $capabilities ) ) { + $capabilities = array( 'manage_options' ); + } + + // Check if required functions exist. + if ( ! function_exists( 'current_user_can' ) ) { + return false; + } + + // If admin context is required, check it first. + if ( $require_admin ) { + if ( ! function_exists( 'is_admin' ) || ! is_admin() ) { + return false; + } + } + + // Check if user has at least one of the required capabilities. + foreach ( $capabilities as $capability ) { + if ( is_string( $capability ) && current_user_can( $capability ) ) { + return true; + } + } + + return false; + } + + /** + * The function retrieves the domain part of the email + * + * @param string $email - the email whose domain has to be validated. + * + * @return bool|string + */ + public static function get_domain( $email ) { + $domain_name = substr( strrchr( $email, '@' ), 1 ); + return $domain_name; + } + + + /** + * This function validates the phone number format. Makes sure that country code + * is appended to the phone number. Return True or false. + * + * @param string $phone - the phone number to be validated. + * + * @return false|int + */ + public static function validate_phone_number( $phone ) { + $phone = self::process_phone_number( $phone ); + + // Basic format validation using regex patterns. + if ( ! preg_match( MoConstants::PATTERN_PHONE, $phone ) ) { + return false; + } + + // Get country code from phone number. + $country_code = self::get_country_code( $phone ); + if ( ! $country_code ) { + return false; + } + + // Extract national significant number (without country code). + $nsn = substr( $phone, strlen( $country_code ) ); + $nsn_length = strlen( $nsn ); + if ( 0 === $nsn_length ) { + return false; + } + $first_digit = substr( $nsn, 0, 1 ); + + // Find the best matching country for this country code using prefixes; keep first match as fallback. + $country_list = CountryList::get_countrycode_list(); + $country_data = null; + $fallback = null; + foreach ( $country_list as $cand ) { + if ( ! isset( $cand['countryCode'] ) || $cand['countryCode'] !== $country_code ) { + continue; + } + if ( null === $fallback ) { + $fallback = $cand; + } + if ( isset( $cand['prefixes'] ) && is_array( $cand['prefixes'] ) && in_array( $first_digit, $cand['prefixes'], true ) ) { + $country_data = $cand; + break; + } + } + if ( ! $country_data && $fallback ) { + $country_data = $fallback; + } + if ( ! $country_data ) { + return false; + } + + // Validate length using min/max from metadata if present; else default 7-15 digits. + $min_len = ( isset( $country_data['minLength'] ) && is_numeric( $country_data['minLength'] ) ) ? (int) $country_data['minLength'] : 7; + $max_len = ( isset( $country_data['maxLength'] ) && is_numeric( $country_data['maxLength'] ) ) ? (int) $country_data['maxLength'] : 15; + if ( $nsn_length < $min_len || $nsn_length > $max_len ) { + return false; + } + + // Validate allowed first-digit prefixes if provided. + if ( isset( $country_data['prefixes'] ) && is_array( $country_data['prefixes'] ) && ! empty( $country_data['prefixes'] ) ) { + if ( ! in_array( $first_digit, $country_data['prefixes'], true ) ) { + return false; + } + } + + return true; + } + + + /** + * This function validates the phone number format and checks if it has country code appended. + * Return True or false. + * + * @param string $phone - the phone number to be checked. + * + * @return bool + */ + public static function is_country_code_appended( $phone ) { + return preg_match( MoConstants::PATTERN_COUNTRY_CODE, $phone, $matches ) ? true : false; + } + + /** + * Process the phone number, return the country code appended to the phone number. If + * country code is not appended then return the default country code if set any by the + * admin. + * + * @param string $phone - the phone number to be processed. + * + * @return mixed + */ + public static function get_country_code( $phone ) { + if ( ! $phone ) { + return; + } + $phone = preg_replace( MoConstants::PATTERN_SPACES_HYPEN, '', ltrim( trim( $phone ), '0' ) ); + $default_country_code = CountryList::get_default_countrycode(); + $country_list = CountryList::get_countrycode_list(); + if ( ! self::is_country_code_appended( $phone ) ) { + return $default_country_code; + } + usort( + $country_list, + function ( $country_a, $country_b ) { + return strlen( $country_b['countryCode'] ) - strlen( $country_a['countryCode'] ); + } + ); + foreach ( $country_list as $country_data ) { + if ( strpos( $phone, $country_data['countryCode'] ) === 0 ) { + return $country_data['countryCode']; + } + } + } + + /** + * Process the phone number. Check if country code is appended to the phone number. If + * country code is not appended then add the default country code if set any by the + * admin. + * + * @param string $phone - the phone number to be processed. + * + * @return mixed + */ + public static function process_phone_number( $phone ) { + if ( ! $phone ) { + return; + } + $phone = preg_replace( MoConstants::PATTERN_SPACES_HYPEN, '', ltrim( trim( $phone ), '0' ) ); + $default_country_code = CountryList::get_default_countrycode(); + $phone = ! isset( $default_country_code ) || self::is_country_code_appended( $phone ) ? $phone : $default_country_code . $phone; + return apply_filters( 'mo_process_phone', $phone ); + } + + + /** + * Checks if user has completed his registration in miniOrange. + * + * @return int + */ + public static function micr() { + $email = get_mo_option( 'admin_email' ); + $customer_key = get_mo_option( 'admin_customer_key' ); + if ( ! $email || ! $customer_key || ! is_numeric( trim( $customer_key ) ) ) { + return 0; + } else { + return 1; + } + } + + /** + * Checks the class for license library and returns bool by checking if the license is expired. + * + * @return bool|array + */ + public static function mllc() { + $is_free_plugin = strcmp( MOV_TYPE, 'MiniOrangeGateway' ) === 0; + return ( class_exists( MoConstants::LICENCE_LIBRARY, false ) || ( ! $is_free_plugin ) ) ? Mo_License_Service::is_license_expired() : array( 'STATUS' => false ); + } + /** + * Function generates a random alphanumeric value and returns it. + * + * @return string + */ + public static function rand() { + $length = wp_rand( 0, 15 ); + $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $random_string = ''; + for ( $i = 0; $i < $length; $i++ ) { + $random_string .= $characters[ wp_rand( 0, strlen( $characters ) - 1 ) ]; + } + return $random_string; + } + + + /** + * Checks if user has upgraded to one of the plans. + * + * @return int + */ + public static function micv() { + $email = get_mo_option( 'admin_email' ); + $customer_key = get_mo_option( 'admin_customer_key' ); + $check_ln = get_mo_option( 'check_ln' ); + if ( ! $email || ! $customer_key || ! is_numeric( trim( $customer_key ) ) ) { + return 0; + } else { + return $check_ln ? $check_ln : 0; + } + } + + /** + * This function checks the license of the customer. Updates the license plan, + * sms and email remaining values in the database if user has upgraded. + * + * @param string $show_message - show message or not. + * @param string $customer_key - customerKey of the admin. + * @param string $api_key - apiKey of the admin. + * @return void + */ + public static function handle_mo_check_ln( $show_message, $customer_key, $api_key ) { + $msg = MoMessages::FREE_PLAN_MSG; + $plan = array(); + + $gateway = GatewayFunctions::instance(); + $content = json_decode( MocURLCall::check_customer_ln( $customer_key, $api_key, $gateway->get_application_name(), 'PREMIUM' ), true ); + if ( isset( $content['status'] ) && strcasecmp( $content['status'], 'SUCCESS' ) === 0 ) { + + $email_remaining = isset( $content['emailRemaining'] ) ? $content['emailRemaining'] : 0; + $sms_remaining = isset( $content['smsRemaining'] ) ? $content['smsRemaining'] : 0; + $license_plan = isset( $content['licensePlan'] ) ? $content['licensePlan'] : ''; + + if ( self::sanitize_check( 'licensePlan', $content ) ) { + if ( 0 === strcmp( MOV_TYPE, 'MiniOrangeGateway' ) || 0 === strcmp( MOV_TYPE, 'EnterpriseGatewayWithAddons' ) ) { + $msg = MoMessages::REMAINING_TRANSACTION_MSG; + $plan = array( + 'plan' => $license_plan, + 'sms' => $sms_remaining, + 'email' => $email_remaining, + ); + + } else { + $msg = MoMessages::UPGRADE_MSG; + $plan = array( 'plan' => $license_plan ); + } + update_mo_option( 'check_ln', $license_plan ); + } + update_mo_option( 'customer_license_plan', $license_plan ); + update_mo_option( 'email_transactions_remaining', $email_remaining ); + update_mo_option( 'phone_transactions_remaining', $sms_remaining ); + } else { + $content = json_decode( MocURLCall::check_customer_ln( $customer_key, $api_key, 'wp_email_verification_intranet', 'PREMIUM' ), true ); + $email_remaining = isset( $content['emailRemaining'] ) ? $content['emailRemaining'] : 0; + $sms_remaining = isset( $content['smsRemaining'] ) ? $content['smsRemaining'] : 0; + $license_plan = isset( $content['licensePlan'] ) ? $content['licensePlan'] : ''; + update_mo_option( 'customer_license_plan', $license_plan ); + update_mo_option( 'email_transactions_remaining', $email_remaining ); + update_mo_option( 'phone_transactions_remaining', $sms_remaining ); + + if ( self::sanitize_check( 'licensePlan', $content ) ) { + $msg = MoMessages::INSTALL_PREMIUM_PLUGIN; + } + } + + if ( isset( $content['status'] ) && strcasecmp( $content['status'], 'FAILED' ) === 0 ) { + $content = json_decode( MocURLCall::check_customer_ln( $customer_key, $api_key, '' ), true ); + $email_remaining = isset( $content['emailRemaining'] ) ? $content['emailRemaining'] : 0; + $sms_remaining = isset( $content['smsRemaining'] ) ? $content['smsRemaining'] : 0; + $license_plan = isset( $content['licenseType'] ) ? $content['licenseType'] : ''; + update_mo_option( 'customer_license_plan', $license_plan ); + update_mo_option( 'email_transactions_remaining', $email_remaining ); + update_mo_option( 'phone_transactions_remaining', $sms_remaining ); + } + if ( isset( $content['licenseExpiry'] ) && strcasecmp( $content['status'], 'SUCCESS' ) === 0 ) { + if ( class_exists( MoConstants::LICENCE_LIBRARY, false ) ) { + Mo_License_Service::update_license_expiry( $content['licenseExpiry'] ); + } + } + + if ( $show_message ) { + do_action( 'mo_registration_show_message', MoMessages::showMessage( $msg, $plan ), 'SUCCESS' ); + } + } + + + /** + * Initialize the form session indicating that the OTP Verification for the + * form has started. + * + * @param string $form - form for which session is being initialized / session constant name. + */ + public static function initialize_transaction( $form ) { + if ( empty( $form ) ) { + return; + } + + MoPHPSessions::bootstrap(); + MoPHPSessions::check_session(); + + $reflect = new ReflectionClass( FormSessionVars::class ); + foreach ( $reflect->getConstants() as $key => $value ) { + // Don't unset the current form session variable as we're about to initialize it. + if ( $value !== $form ) { + MoPHPSessions::unset_session( $value ); + } + } + + SessionUtils::initialize_form( $form ); + } + + + /** + * Returns the invalid OTP message. This function checks if admin has set an + * invalid otp message in the settings. If so then that is returned instead of the default. + * + * @return string + */ + public static function get_invalid_otp_method() { + return get_mo_option( 'invalid_message', 'mo_otp_' ) ? get_mo_option( 'invalid_message', 'mo_otp_' ) + : MoMessages::showMessage( MoMessages::INVALID_OTP ); + } + + + /** + * Returns TRUE or FALSE depending on if the POLYLANG plugin is active. + * This is used to check if the translation should use the polylang + * function or the default local translation. + * + * @return boolean + */ + public static function is_polylang_installed() { + return function_exists( 'pll__' ) && function_exists( 'pll_register_string' ); + } + + /** + * Take an array of string having the keyword to replace + * and the keyword to be replaced. This is used to modify + * the SMS templates that the user might have saved in the + * settings or the default ones by the plugin. + * + * @param array $replace The array containing search and replace keywords. + * @param string $input_string Entire string to be modified. + * + * @return mixed + */ + public static function replace_string( array $replace, $input_string ) { + foreach ( $replace as $key => $value ) { + $input_string = str_replace( '{' . $key . '}', $value, $input_string ); + } + + return $input_string; + } + + /** + * Returns a stdClass Object with status Success as a + * temporary result when TEST_MODE is on + * + * @return stdClass + */ + private static function test_result() { + $temp = new stdClass(); + $temp->status = MO_FAIL_MODE ? 'ERROR' : 'SUCCESS'; + return $temp; + } + + /** + * Checks if the whatsapp notifications and presonal business account is enabled + * + * @return bool + */ + public static function mo_is_whatsapp_notif_enabled() { + return get_mo_option( 'mo_whatsapp_enable' ) + && get_mo_option( 'mo_whatsapp_notification_enable' ) + && get_mo_option( 'mo_whatsapp_type' ) === 'bussiness_whatsapp'; + } + + + /** + * Send the notification to the number provided and + * process the response to check if the message was sent + * successfully or not. Return TRUE or FALSE based on the + * API call response. + * + * @param string $number the number to be sent. + * @param string $msg the message to be sent. + * @param string $notification_type the specific type of notification (e.g., 'NEW_ACCOUNT', 'ORDER_STATUS'). + * + * @return bool + */ + public static function send_phone_notif( $number, $msg, $notification_type = 'NOTIFICATION' ) { + + $api_call_result = function ( $number, $msg ) { + return json_decode( MocURLCall::send_notif( new NotificationSettings( $number, $msg ) ) ); + }; + + $mle = self::mllc(); + if ( $mle['STATUS'] ) { + return false; + } + $number = self::process_phone_number( $number ); + $msg = self::replace_string( array( 'phone' => str_replace( '+', '', '%2B' . $number ) ), $msg ); + $content = MO_TEST_MODE ? self::test_result() : $api_call_result( $number, $msg ); + $notif_status = strcasecmp( $content->status, 'SUCCESS' ) === 0 ? 'SMS_NOTIF_SENT' : 'SMS_NOTIF_FAILED'; + apply_filters( 'mo_start_reporting', null, $number, $number, $notification_type . '_PHONE_NOTIF', $msg, $notif_status ); + return strcasecmp( $content->status, 'SUCCESS' ) === 0 ? true : false; + } + + /** + * Send the notification to the number provided and + * process the response to check if the message was sent + * successfully or not. Return TRUE or FALSE based on the + * API call response. + * + * @param string $number the number to be sent. + * @param string $template_name the template name. + * @param string $sms_tags the tags used in sms template. + * @param string $notification_type the specific type of notification (e.g., 'NEW_ACCOUNT', 'ORDER_STATUS'). + * + * @return bool + */ + public static function mo_send_whatsapp_notif( $number, $template_name, $sms_tags, $notification_type = 'WHATSAPP_NOTIFICATION' ) { + $api_call_result = function ( $number, $data ) { + return apply_filters( 'mo_wa_send_otp_token', 'WHATSAPP_NOTIFICATION', null, null, $number, $data ); + }; + + $data = array( + 'template_name' => $template_name, + 'sms_tags' => $sms_tags, + ); + $number = self::process_phone_number( $number ); + $content = MO_TEST_MODE ? self::test_result() : $api_call_result( $number, $data ); + $notif_status = strcasecmp( $content->status, 'SUCCESS' ) === 0 ? 'SMS_NOTIF_SENT' : 'SMS_NOTIF_FAILED'; + apply_filters( 'mo_start_reporting', null, $number, $number, $notification_type . '_WHATSAPP_NOTIF', $template_name, $notif_status ); + return strcasecmp( $content->status, 'SUCCESS' ) === 0 ? true : false; + } + + + /** + * Send the notification to the email provided and + * process the response to check if the message was sent + * successfully or not. Return TRUE or FALSE based on the + * API call response. + * + * @param string $from_email The From Email. + * @param string $from_name The From Name. + * @param string $to_email The email to send message to. + * @param string $subject The subject of the email. + * @param string $message The message to be sent. + * + * @return bool + */ + public static function send_email_notif( $from_email, $from_name, $to_email, $subject, $message ) { + $api_call_result = function ( $from_email, $from_name, $to_email, $subject, $message ) { + $notification_settings = new NotificationSettings(); + $notification_settings->create_email_notification_settings( $from_email, $from_name, $to_email, $subject, $message ); + return json_decode( MocURLCall::send_notif( $notification_settings ) ); + }; + $content = MO_TEST_MODE ? self::test_result() : $api_call_result( $from_email, $from_name, $to_email, $subject, $message ); + return strcasecmp( $content->status, 'SUCCESS' ) === 0 ? true : false; + } + /** + * Check if there is an existing value in the array/buffer and return the value + * that exists against that key otherwise return false. + * <p></p> + * The function also makes sure to sanitize the values being fetched. + * <p></p> + * If the buffer to fetch the value from is not an array then return buffer as it is. + * + * @param string $key the key to check against. + * @param string|array $buffer the post/get or array. + * @return string|bool|array + */ + public static function sanitize_check( $key, $buffer ) { + if ( ! isset( $buffer[ $key ] ) ) { + return false; + } + if ( is_array( $buffer[ $key ] ) ) { + return self::mo_sanitize_array( $buffer[ $key ] ); + } else { + return sanitize_text_field( wp_unslash( $buffer[ $key ] ) ); + } + } + + /** + * Checks if user has upgraded to the on-prem plugin + */ + public static function mclv() { + $gateway = GatewayFunctions::instance(); + return $gateway->mclv(); + } + + + /**Checks if the current plugin is Custom Gateway Plugin + */ + public static function is_gateway_config() { + $gateway = GatewayFunctions::instance(); + return $gateway->is_gateway_config(); + } + + /** + * Checks if the current plugin is MiniOrangeGateway Plugin + * + * @return bool + */ + public static function is_mg() { + $gateway = GatewayFunctions::instance(); + return $gateway->is_mg(); + } + + + /** + * This function checks if all conditions to save the form settings + * are true. This checks if the user saving the form settings is an admin, + * has registered with miniorange and the the form post has an option value + * mo_customer_validation_settings + * + * @param string $key_val the key to check against (typically 'mo_customer_validation_settings'). + * @param string $form_option_key Optional POST key that must be present to proceed (form-specific). + * + * @return bool + */ + public static function are_form_options_being_saved( $key_val, $form_option_key = '' ) { + if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['_wpnonce'] ) ), 'mo_admin_actions' ) ) { + return false; + } + if ( ! current_user_can( 'manage_options' ) ) { + return false; + } + if ( ! self::mclv() ) { + return false; + } + + $option_value = isset( $_POST['option'] ) ? sanitize_text_field( wp_unslash( $_POST['option'] ) ) : ''; + if ( empty( $option_value ) || $option_value !== $key_val ) { + return false; + } + if ( ! empty( $form_option_key ) ) { + $prefixed_key = 'mo_customer_validation_' . $form_option_key; + $form_key_exists = isset( $_POST[ $prefixed_key ] ) || isset( $_POST[ $form_option_key ] ); + if ( $form_key_exists ) { + return true; + } + // Form checkbox doesn't exist - form might be disabled, but we're in form settings save context. + $form_base = str_replace( '_enable', '', $form_option_key ); + $parts = explode( '_', $form_base ); + $form_id = $parts[0]; + $form_data_key = str_replace( '_enable', '_form', $form_option_key ); + if ( isset( $_POST[ $form_data_key ] ) ) { + return true; + } + foreach ( $_POST as $key => $value ) { + $key_to_check = $key; + if ( strpos( $key, 'mo_customer_validation_' ) === 0 ) { + $key_to_check = str_replace( 'mo_customer_validation_', '', $key ); + } elseif ( strpos( $key, 'mo_' ) === 0 ) { + $key_to_check = str_replace( 'mo_', '', $key ); + } + if ( strpos( $key_to_check, $form_id . '_' ) === 0 || strpos( $key_to_check, $form_base ) === 0 ) { + return true; + } + } + // Return false to prevent unnecessary processing. + return false; + } + return true; + } + + /** + * Update SMS Email transaction in DataBase + * + * @param string $response Response form Gateway. + * @param string $type OTP Type email or phone. + */ + public static function mo_update_sms_email_transations( $response, $type ) { + $content = json_decode( $response ); + if ( strcasecmp( $content->status, 'SUCCESS' ) === 0 ) { + $option_type = ( VerificationType::PHONE === $type ) ? 'phone_transactions_remaining' : 'email_transactions_remaining'; + $remaining_txn = get_mo_option( $option_type ); + if ( $remaining_txn > 0 ) { + update_mo_option( $option_type, $remaining_txn - 1 ); + } + } + } + + /** + * Update WhatsApp transaction in DataBase + * + * @param string $response Resposne form Gateway. + */ + public static function mo_update_whatsapp_transations( $response ) { + $content = json_decode( $response ); + if ( isset( $content->status ) && strcasecmp( $content->status, 'SUCCESS' ) === 0 ) { + $remaining_txn = get_mo_option( 'whatsapp_transactions_remaining', 'mowp_customer_validation_' ); + if ( $remaining_txn > 0 ) { + update_mo_option( 'whatsapp_transactions_remaining', $remaining_txn - 1, 'mowp_customer_validation_' ); + } + } + } + + /** + * Checks if the customer is registered or not and shows a message on the page + * to the user so that they can register or login themselves to use the plugin. + */ + public static function is_addon_activated() { + if ( self::micr() && self::mclv() ) { + return; + } + $tab_details = TabDetails::instance(); + $server_uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; + $registration_url = add_query_arg( + array( 'page' => $tab_details->tab_details[ Tabs::ACCOUNT ]->menu_slug ), + remove_query_arg( 'addon', $server_uri ) + ); + echo '<div style="display:block;margin-top:10px;color:red;background-color:rgba(251, 232, 0, 0.15); + padding:5px;border:solid 1px rgba(255, 0, 9, 0.36);"> + <a href="' . esc_url( $registration_url ) . '">' . esc_html( __( 'Validate your purchase', 'miniorange-otp-verification' ) ) . '</a> + ' . esc_html( __( ' to enable the Add On', 'miniorange-otp-verification' ) ) . '</div>'; + } + + /** + * Check if the phone number is empty and return error. + * + * @param string $phone_number phone number of the user. + */ + public static function check_if_phone_exist( $phone_number ) { + if ( empty( $phone_number ) ) { + wp_send_json( self::create_json( MoMessages::showMessage( MoMessages::PHONE_NOT_FOUND ), MoConstants::ERROR_JSON_TYPE ) ); + } + } + + /** + * Checks the version of the plugin active with the mentioned name. + * + * @param string $plugin_name - Plugin Name. + * @param integer $sequence - index of the version digit to get. + * @return integer Version number. + */ + public static function get_active_plugin_version( $plugin_name, $sequence = 0 ) { + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + $all_plugins = get_plugins(); + $active_plugin = get_mo_option( 'active_plugins', '' ); + if ( ! is_array( $active_plugin ) ) { + $active_plugin = array(); + } + foreach ( $all_plugins as $key => $value ) { + if ( isset( $value['Name'] ) && strcasecmp( $value['Name'], $plugin_name ) === 0 ) { + if ( in_array( $key, $active_plugin, true ) ) { + // Make sure the version is set and has the requested sequence. + if ( isset( $value['Version'] ) && isset( $value['Version'][ $sequence ] ) ) { + return (int) $value['Version'][ $sequence ]; + } + } + } + } + return null; + } + + /** + * Encrypts a plaintext password using AES-256-CBC encryption. + * + * @param string $plaintext_password The plain text password to encrypt. + * @return string Hex-encoded encrypted string. + */ + public static function encrypt_password( $plaintext_password ) { + if ( empty( $plaintext_password ) ) { + return ''; + } + $encryption_key = hash( 'sha256', wp_salt( 'auth' ), true ); + $iv = substr( hash( 'sha256', 'otp-plugin-password-iv' ), 0, 16 ); + $encrypted = openssl_encrypt( + $plaintext_password, + 'AES-256-CBC', + $encryption_key, + 0, + $iv + ); + if ( false === $encrypted ) { + return ''; + } + return bin2hex( $encrypted ); + } + + /** + * Decrypts an AES-256-CBC encrypted password back to plain text. + * + * @param string $encrypted_password The hex-encoded encrypted password. + * @return string|false Decrypted plain text password, or false on failure. + */ + public static function decrypt_password( $encrypted_password ) { + if ( empty( $encrypted_password ) ) { + return ''; + } + $encryption_key = hash( 'sha256', wp_salt( 'auth' ), true ); + $iv = substr( hash( 'sha256', 'otp-plugin-password-iv' ), 0, 16 ); + $encrypted_data = hex2bin( $encrypted_password ); + if ( false === $encrypted_data ) { + return ''; + } + $decrypted = openssl_decrypt( + $encrypted_data, + 'AES-256-CBC', + $encryption_key, + 0, + $iv + ); + return false === $decrypted ? '' : $decrypted; + } + + /** + * Get the current user's IP address. + * + * @return string - IP address + */ + public static function get_current_ip_address() { + $ip_sources = array( + 'REMOTE_ADDR' => array( + 'trust' => true, + 'risk' => 'low', + ), + 'HTTP_X_FORWARDED_FOR' => array( + 'trust' => false, + 'risk' => 'medium', + ), + 'HTTP_X_REAL_IP' => array( + 'trust' => false, + 'risk' => 'low', + ), + 'HTTP_CF_CONNECTING_IP' => array( + 'trust' => false, + 'risk' => 'low', + ), + 'HTTP_CLIENT_IP' => array( + 'trust' => false, + 'risk' => 'high', + ), + ); + + $found_ips = array(); + $suspicious_ips = array(); + + foreach ( $ip_sources as $source => $metadata ) { + if ( empty( $_SERVER[ $source ] ) ) { + continue; + } + + $raw_value = sanitize_text_field( wp_unslash( $_SERVER[ $source ] ) ); + $ip = self::extract_first_valid_ip( $raw_value ); + + if ( $ip && self::is_valid_ip( $ip ) ) { + $found_ips[] = array( + 'ip' => $ip, + 'trust' => $metadata['trust'], + 'risk' => $metadata['risk'], + ); + } else { + $suspicious_ips[] = array( + 'ip' => $ip ? $ip : 'invalid_format', + 'source' => $source, + 'raw_value' => $raw_value, + 'reason' => $ip ? 'contains_attack_patterns' : 'invalid_ip_format', + ); + } + } + + if ( ! empty( $found_ips ) ) { + usort( + $found_ips, + function ( $a, $b ) { + if ( $a['trust'] === $b['trust'] ) { + $risk_order = array( + 'low' => 0, + 'medium' => 1, + 'high' => 2, + ); + return $risk_order[ $a['risk'] ] - $risk_order[ $b['risk'] ]; + } + return $b['trust'] - $a['trust']; + } + ); + + $best_ip = $found_ips[0]; + $risk_markers = array( + 'high' => ' (high_risk_proxy)', + 'medium' => ' (medium_risk_proxy)', + ); + + return $best_ip['ip'] . ( $risk_markers[ $best_ip['risk'] ] ?? '' ); + } + + if ( isset( $_SERVER['REMOTE_ADDR'] ) ) { + $fallback_ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ); + return self::is_valid_ip( $fallback_ip ) ? $fallback_ip : $fallback_ip . ' (suspicious_format)'; + } + + if ( ! empty( $suspicious_ips ) ) { + self::log_suspicious_ip_activity( $suspicious_ips ); + $first_suspicious = $suspicious_ips[0]; + return 'invalid_format' !== $first_suspicious['ip'] ? $first_suspicious['ip'] : 'Unknown'; + } + + return 'Unknown'; + } + + /** + * Get the first matching page ID by its title using a non-deprecated approach. + * + * @param string $page_title Page title to search for. + * @param string $post_status Page status to include (default 'all'). + * @return int|string Page ID if found; empty string if not found or title blank. + */ + public static function mo_get_page_id_by_title( $page_title, $post_status = 'all' ) { + if ( self::is_blank( $page_title ) ) { + return ''; + } + + $pages = get_posts( + array( + 'post_type' => 'page', + 'title' => $page_title, + 'post_status' => $post_status, + 'numberposts' => 1, + 'suppress_filters' => false, + ) + ); + + return ( ! empty( $pages ) && isset( $pages[0]->ID ) ) ? (int) $pages[0]->ID : ''; + } + + /** + * Get the permalink for the first matching page title, or a default if not found. + * + * @param string $page_title Page title to search for. + * @param string $default_url Default URL to return if not found (default home_url('/')). + * @param string $post_status Page status to include (default 'all'). + * @return string Resolved permalink or default. + */ + public static function mo_get_permalink_by_page_title( $page_title, $default_url = '', $post_status = 'all' ) { + $default = $default_url ? $default_url : home_url( '/' ); + $page_id = self::mo_get_page_id_by_title( $page_title, $post_status ); + $redirect_url = $page_id ? get_permalink( $page_id ) : $default; + return esc_url_raw( $redirect_url ); + } + + /** + * Check for suspicious IP patterns (disabled for now). + * + * @param string $ip - IP address to validate. + * @return bool - True if valid and safe + */ + private static function is_valid_ip( $ip ) { + if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6 ) ) { + return false; + } + if ( self::contains_attack_patterns( $ip ) ) { + return false; + } + return true; + } + + /** + * Check for attack patterns in IP string. + * + * @param string $ip - IP address to check. + * @return bool - True if contains attack patterns + */ + private static function contains_attack_patterns( $ip ) { + $suspicious_chars = array( '<', '>', '"', "'", '\\', '/', '&', ';', '(', ')' ); + foreach ( $suspicious_chars as $char ) { + if ( strpos( $ip, $char ) !== false ) { + return true; + } + } + return false; + } + + /** + * Get relevant server variables for security logging. + * + * @return array - Sanitized server variables + */ + private static function get_relevant_server_vars() { + $relevant_vars = array( + 'REMOTE_ADDR', + 'HTTP_X_FORWARDED_FOR', + 'HTTP_X_REAL_IP', + 'HTTP_CF_CONNECTING_IP', + 'HTTP_CLIENT_IP', + 'HTTP_USER_AGENT', + 'REQUEST_URI', + 'REQUEST_METHOD', + 'HTTP_REFERER', + ); + + $server_data = array(); + foreach ( $relevant_vars as $var ) { + if ( isset( $_SERVER[ $var ] ) ) { + $server_data[ $var ] = sanitize_text_field( wp_unslash( $_SERVER[ $var ] ) ); + } + } + + return $server_data; + } + + /** + * Validate IP address with basic security checks. + * + * @param string $ip_string - Comma-separated IP addresses. + * @return string|false - First valid IP or false + */ + private static function extract_first_valid_ip( $ip_string ) { + $ips = explode( ',', $ip_string ); + foreach ( $ips as $ip ) { + $ip = trim( $ip ); + + if ( empty( $ip ) || ! is_string( $ip ) ) { + continue; + } + + if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6 ) ) { + return $ip; + } + } + return false; + } + + /** + * Log suspicious IP activity for security analysis. + * + * @param array $suspicious_ips - Array of suspicious IP data. + */ + private static function log_suspicious_ip_activity( $suspicious_ips ) { + $log_data = array( + 'timestamp' => current_time( 'mysql' ), + 'user_agent' => sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown' ) ), + 'request_uri' => sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? 'Unknown' ) ), + 'suspicious_ips' => $suspicious_ips, + 'all_server_vars' => self::get_relevant_server_vars(), + ); + + $security_logs = get_mo_option( 'mo_otp_security_logs', array() ); + $security_logs[] = $log_data; + + if ( count( $security_logs ) > 100 ) { + $security_logs = array_slice( $security_logs, -100 ); + } + + update_mo_option( 'mo_otp_security_logs', $security_logs ); + } + + /** + * Get current page parameter value from URL query string. + * This function safely retrieves GET parameters without triggering PHPCS nonce verification warnings. + * It parses the REQUEST_URI to extract query parameters, which is safe for routing/display purposes. + * + * @param string $parameter_name The name of the parameter to retrieve. + * @param string $default_value Default value to return if parameter is not found. + * @return string The parameter value or default value. + */ + public static function get_current_page_parameter_value( $parameter_name, $default_value = '' ) { + $path = ! empty( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; + + $parameter_value = ''; + + // Parse the URL to get the query string. + $query_str = wp_parse_url( $path, PHP_URL_QUERY ); + + // Parse the query string into an array. + if ( $query_str ) { + parse_str( $query_str, $query_params ); + + // Get the parameter value if it exists. + if ( ! empty( $query_params[ $parameter_name ] ) ) { + $parameter_value = sanitize_text_field( $query_params[ $parameter_name ] ); + } + unset( $query_params ); + } + + return ! empty( $parameter_value ) ? $parameter_value : $default_value; + } + } +} @@ -103,13 +103,13 @@ public function enqueue_visual_tour_script() { wp_register_script( 'tourScript', MOV_URL . 'includes/js/visualTour.js?version=' . MOV_VERSION, array( 'jquery' ), MOV_VERSION, false ); $page = MoUtility::get_current_page_parameter_value( 'page', '' ); - $path = ! empty( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; - $query_str = wp_parse_url( $path, PHP_URL_QUERY ); - $current_get = array(); - if ( $query_str ) { - parse_str( $query_str, $query_params ); - $current_get = MoUtility::mo_sanitize_array( $query_params ); - unset( $query_params ); + $allowed_keys = array( 'page', 'form', 'subpage', 'addon' ); + $current_get = array(); + foreach ( $allowed_keys as $param ) { + $val = MoUtility::get_current_page_parameter_value( $param, '' ); + if ( '' !== $val ) { + $current_get[ $param ] = $val; + } } wp_localize_script( 'tourScript', @@ -16,6 +16,7 @@ use OTP\Traits\Instance; use OTP\Helper\MoUtility; use OTP\Helper\MoPHPSessions; +use OTP\Helper\CountryList; /** * This is the External Popup class. This class handles all the @@ -256,10 +257,11 @@ 'moExternalPopUps', 'moExternalPopUps', array( - 'secure_site_url' => esc_url( admin_url( 'admin-ajax.php' ) ), - 'resend_otp_text' => esc_js( $resend_label ), - 'home_url' => esc_url( home_url() ), - 'login_page_url' => esc_url( $current_url ), + 'secure_site_url' => esc_url( admin_url( 'admin-ajax.php' ) ), + 'resend_otp_text' => esc_js( $resend_label ), + 'home_url' => esc_url( home_url() ), + 'login_page_url' => esc_url( $current_url ), + 'default_country_code' => esc_js( (string) CountryList::get_default_countrycode() ), ) ); wp_print_scripts( 'moExternalPopUps' ); @@ -209,43 +209,25 @@ if (movarlogin.phoneOnlyIdentifiers && movarlogin.phoneOnlyLoginMessage) { $mo('#loginform').on('submit', function (e) { - var user = ($mo('#user_login').val() || '').trim(); var pass = ($mo('#user_pass').val() || '').trim(); var passRowVisible = $mo("#loginform label[for='user_pass']").parent().is(':visible'); - if (moLooksLikeEmailLoginIdentifier(user)) { - e.preventDefault(); - alert(movarlogin.phoneOnlyLoginMessage); - return false; - } if (passRowVisible && pass.length > 0) { return true; } }); $mo('.woocommerce-form-login').on('submit', function (e) { var $form = $mo(this); - var user = ($mo('#username', $form).val() || '').trim(); var pass = ($mo('#password', $form).val() || '').trim(); var passVisible = $mo('label[for="password"]', $form).parent().is(':visible') || ($mo('#password', $form).length && $mo('#password', $form).closest('.woocommerce-form-row, p').is(':visible')); - if (moLooksLikeEmailLoginIdentifier(user)) { - e.preventDefault(); - alert(movarlogin.phoneOnlyLoginMessage); - return false; - } if (passVisible && pass.length > 0) { return true; } }); $mo('.um-login form').on('submit', function (e) { var $form = $mo(this); - var user = ($mo('input[name^="username"]', $form).first().val() || '').trim(); var pass = ($mo('input[name^="user_password"]', $form).first().val() || '').trim(); var passVisible = $mo('.um-field-password', $form).is(':visible'); - if (moLooksLikeEmailLoginIdentifier(user)) { - e.preventDefault(); - alert(movarlogin.phoneOnlyLoginMessage); - return false; - } if (passVisible && pass.length > 0) { return true; } @@ -34,15 +34,23 @@ return false; // Prevent AJAX call } - // Validate phone number format (basic check - should start with +) + // If phone lacks a country code, prepend the default if one is configured var phonePattern = /^\+/; if (!phonePattern.test(e.trim())) { - $mo("#mo_message").empty(); - $mo("#mo_message").text('Please enter a valid phone number starting with + (e.g., +1XXXXXXXXXX).'); - $mo("#mo_message").css("background-color","#eda58e"); - $mo("#mo_message").show(); - $mo("input[name=mo_phone_number]").focus(); - return false; // Prevent AJAX call + var defaultCode = (typeof moExternalPopUps !== 'undefined' && moExternalPopUps.default_country_code) + ? moExternalPopUps.default_country_code + : ''; + if (defaultCode) { + e = defaultCode + e.trim(); + $mo("input[name=mo_phone_number]").val(e); + } else { + $mo("#mo_message").empty(); + $mo("#mo_message").text('Please enter a valid phone number starting with + (e.g., +1XXXXXXXXXX).'); + $mo("#mo_message").css("background-color","#eda58e"); + $mo("#mo_message").show(); + $mo("input[name=mo_phone_number]").focus(); + return false; + } } $mo("#mo_message").empty(); @@ -1,598 +1,598 @@ -jQuery(document).ready(function () { - if (typeof jQuery.fn.$mo !== 'function') { - jQuery.fn.$mo = function () { - return this; - }; - } - var $mo = jQuery; - var popupInitialized = false; - - /** WC popup message: plain text only (no inline error/success colors left from spam preventer). */ - function moWcPopupMessageStripStyles() { - var $m = jQuery('#mo_message_wc_pop_up'); - if ($m.length) { - $m.removeAttr('style'); - $m.removeData('mo-osp-error-message'); - } - } - - function check_form_loaded() { - if ($mo('.wc-block-components-address-form__phone input[type="tel"]').length || jQuery(".wc-block-components-text-input input[type=tel]").length) { - // Inject popup markup once the Block Checkout form is present. - if (mowcnewcheckout.popupEnabled && mowcnewcheckout.popupHtml && !popupInitialized) { - mo_add_custom_popup(); - } - - send_and_verify_otp(); - if (mowcnewcheckout.selectivePaymentEnabled) { - check_payment_methods(); - } - } else { - setTimeout(check_form_loaded, 100); - } - } - - check_form_loaded(); - - function mo_add_custom_popup() { - if (popupInitialized || !mowcnewcheckout.popupHtml) { - return; - } - - var $form = jQuery(".wc-block-checkout__form"); - if (!$form.length) { - return; - } - - popupInitialized = true; - - var htmlContent = mowcnewcheckout.popupHtml; - $form.append(htmlContent); - - // Normalize popup markup similar to original inline script. - var $popupForm = jQuery("#mo_validate_form"); - if ($popupForm.length) { - $popupForm.children().appendTo($popupForm.parent()); - $popupForm.remove(); - } - - jQuery('[name="mo_otp_token"]').attr({ id: 'mo_otp_token', name: 'order_verify' }); - - var $oldSubmit = jQuery('[name="miniorange_otp_token_submit"]'); - if ($oldSubmit.length) { - var $newBtn = jQuery('<input>', { - type: 'button', - id: 'miniorange_otp_validate_submit', - class: $oldSubmit.attr('class'), - value: $oldSubmit.attr('value') - }); - $oldSubmit.replaceWith($newBtn); - } - - jQuery('.close').removeAttr('onclick'); - jQuery("#validation_goBack_form, #verification_resend_otp_form, #goBack_choice_otp_form").remove(); - jQuery('a[onclick="mo_otp_verification_resend()"]').attr('id', 'mo_otp_verification_resend').removeAttr('onclick'); - jQuery('.mo_customer_validation-login-container').find('input[type="hidden"]').remove(); - jQuery("#mo_message").remove(); - - attachOtpInputSanitizers(); - } - - function attachOtpInputSanitizers() { - try { - var pattern = /[^a-zA-Z0-9]/g; - if (typeof mowcnewcheckout !== 'undefined' && mowcnewcheckout.popupInputPattern) { - var sanitizedPattern = $mo('<div/>').text(mowcnewcheckout.popupInputPattern).html(); - pattern = new RegExp(sanitizedPattern.replace(/^\/|\/[^\/]*$/g, ''), 'g'); - } - - // Standard OTP input (Default/Streaky) present in checkout popup. - var otpInputs = document.querySelectorAll(".mo_customer_validation-textbox.mo-new-ui-validation-textbox, #mo_otp_token, .otp-streaky-input"); - otpInputs.forEach(function (input) { - input.addEventListener("input", function () { - var originalValue = input.value || ""; - var cleanedValue = originalValue.replace(pattern, ""); - if (originalValue !== cleanedValue) { - input.value = cleanedValue; - } - }); - input.addEventListener("paste", function (e) { - e.preventDefault(); - var pasted = (e.clipboardData || window.clipboardData).getData("text") || ""; - var clean = pasted.replace(pattern, ""); - var start = input.selectionStart || 0; - var end = input.selectionEnd || 0; - var currentValue = input.value || ""; - input.value = currentValue.slice(0, start) + clean + currentValue.slice(end); - if (input.setSelectionRange) { - input.setSelectionRange(start + clean.length, start + clean.length); - } - }); - }); - - // Catchy style individual boxes (keep single char, sanitize). - var catchyInputs = document.querySelectorAll(".digit-group .otp-catchy"); - catchyInputs.forEach(function (box) { - box.setAttribute("maxlength", "1"); - var enforce = function () { - var v = box.value || ""; - var c = v.replace(pattern, ""); - if (c.length > 1) { - c = c.charAt(0); - } - if (v !== c) { - box.value = c; - } - }; - box.addEventListener("input", enforce); - box.addEventListener("paste", function (e) { - e.preventDefault(); - var pasted = (e.clipboardData || window.clipboardData).getData("text") || ""; - var clean = pasted.replace(pattern, ""); - if (clean.length > 1) { - clean = clean.charAt(0); - } - box.value = clean; - }); - }); - } catch (e) { - // Fail silently; sanitizers are a hardening layer. - } - } - - function check_payment_methods() { - let methods = mowcnewcheckout.paymentMethods; - let payment_based_otp = mowcnewcheckout.selectivePaymentEnabled; - - function checkInitialState() { - let hasPaymentMethods = $mo('input[name="radio-control-wc-payment-method-options"]').length > 0 || - $mo('.wc-block-components-payment-methods').length > 0 || - $mo('input[name=payment_method]').length > 0; - - if (hasPaymentMethods) { - toggleSubmitButton(); - } else { - setTimeout(checkInitialState, 100); - } - } - - checkInitialState(); - - setTimeout(function() { - toggleSubmitButton(); - }, 300); - setTimeout(function() { - toggleSubmitButton(); - }, 600); - setTimeout(function() { - toggleSubmitButton(); - }, 1000); - setTimeout(function() { - toggleSubmitButton(); - }, 1500); - - $mo(document).on('click change', 'input[name="radio-control-wc-payment-method-options"], input[name=payment_method]', function () { - toggleSubmitButton(); - }); - - $mo(document).on('wc-blocks-payment-method-selected', function() { - toggleSubmitButton(); - }); - - function toggleSubmitButton() { - let selectedValue = $mo('input[name="radio-control-wc-payment-method-options"]:checked').val(); - - let show_otp_button = false; - - if (!payment_based_otp) { - show_otp_button = true; - } else { - if (selectedValue && methods.hasOwnProperty(selectedValue)) { - show_otp_button = true; - } else { - let blockPaymentMethod = $mo('.wc-block-components-payment-methods input:checked').data('payment-method-id') || - $mo('.wc-block-components-payment-methods input:checked').attr('id'); - if (blockPaymentMethod) { - blockPaymentMethod = blockPaymentMethod.replace('wc-payment-method-', '').replace('payment_method_', ''); - if (methods.hasOwnProperty(blockPaymentMethod)) { - show_otp_button = true; - } - } - - if (!show_otp_button) { - $mo("input[name=payment_method]").each(function () { - let payment = $mo(this).val(); - if ($mo(this).is(':checked') && methods.hasOwnProperty(payment)) { - show_otp_button = true; - return false; - } - }); - } - - if (!show_otp_button) { - let wcPaymentMethod = $mo('input[name="payment_method"]:checked').val(); - if (!wcPaymentMethod) { - let $paymentInputs = $mo('input[name="payment_method"]'); - if ($paymentInputs.length === 1) { - wcPaymentMethod = $paymentInputs.first().val(); - } - } - if (wcPaymentMethod && methods.hasOwnProperty(wcPaymentMethod)) { - show_otp_button = true; - } - } - - if (!show_otp_button) { - let activePaymentMethod = $mo('.wc-block-components-payment-methods .wc-block-components-radio-control__option--checked').find('input').val() || - $mo('.wc-block-components-payment-methods .wc-block-components-radio-control__option--checked').data('value'); - if (activePaymentMethod && methods.hasOwnProperty(activePaymentMethod)) { - show_otp_button = true; - } - } - - if (!show_otp_button && typeof wc !== 'undefined' && wc.wcBlocksData && wc.wcBlocksData.storeApi) { - try { - let storeData = wc.wcBlocksData.storeApi; - if (storeData.paymentMethodData && storeData.paymentMethodData.selectedPaymentMethod) { - let storePaymentMethod = storeData.paymentMethodData.selectedPaymentMethod; - if (methods.hasOwnProperty(storePaymentMethod)) { - show_otp_button = true; - } - } - } catch(e) { - } - } - - if (!show_otp_button) { - $mo('.wc-block-components-payment-methods input[type="radio"]:checked').each(function() { - let payment = $mo(this).val(); - if (payment && methods.hasOwnProperty(payment)) { - show_otp_button = true; - return false; - } - }); - } - - if (!show_otp_button) { - $mo('.wc-block-components-payment-methods input:checked, .wc-block-checkout__payment-methods input:checked').each(function() { - let payment = $mo(this).val() || $mo(this).attr('value'); - if (payment && methods.hasOwnProperty(payment)) { - show_otp_button = true; - return false; - } - }); - } - } - } - - if (mowcnewcheckout.popupEnabled) { - $mo("button#miniorange_wc_popup_send_otp_token").show(); - $mo('.wc-block-components-checkout-place-order-button').hide(); - } else if (show_otp_button) { - $mo("#miniorange_otp_token_submit_wc_block_checkout").show(); - } else { - $mo("#miniorange_otp_token_submit_wc_block_checkout").hide(); - } - } - } - - function send_and_verify_otp() { - let send_verify_otp = "<input type='button' id='miniorange_otp_token_submit_wc_block_checkout' style='width: 100%; padding: 1em; margin: 2% 0%;' class='components-button wc-block-components-button wp-element-button contained' value='" + mowcnewcheckout.buttonText + "'/><div id='mo_message' style='background-color: #f7f6f7; display:none; padding: 1em 2em 1em 3.5em;'></div> <div style='display:none;' id='mo_verify_otp_fields'><div class='wc-block-components-text-input'><input type='text' id='mo_otp_token' aria-label='Enter OTP'/><label for='mo_otp_token'>Enter Verification Code</label></div><input type='button' id='miniorange_verify_otp_token' class='components-button wc-block-components-button wp-element-button contained' style='width: 100%; padding: 1em; margin: 2% 0%;' value='Verify OTP'/></div>"; - let img = "<div class='moloader'></div>"; - - if (mowcnewcheckout.popupEnabled) { - $mo('.wc-block-components-checkout-place-order-button').hide(); - var $wcPopupBtn = $mo('button#miniorange_wc_popup_send_otp_token'); - if ($wcPopupBtn.length === 0) { - $mo('.wc-block-components-checkout-place-order-button').after('<button id="miniorange_wc_popup_send_otp_token" class="wp-element-button" type="button">' + mowcnewcheckout.buttonText + '</button>'); - } else { - $wcPopupBtn.show().prop('disabled', false).css('opacity', ''); - } - - if (jQuery('.otp-catchy-box').length > 0) { - jQuery(".digit-group input.otp-catchy").each(function () { - jQuery(this).attr("maxlength", "1"); - }).on("keyup", function (event) { - let parent = jQuery(this).parent(); - if (event.keyCode === 8 || event.keyCode === 37) { - let prevId = jQuery(this).data("previous"); - if (prevId) { - jQuery("#" + prevId).select(); - } - } else { - let nextId = jQuery(this).data("next"); - if (nextId) { - jQuery("#" + nextId).select(); - } - } - }); - - let mo_validate_button = document.getElementById("mo_sec_otp_submit_button"); - if (mo_validate_button) { - mo_validate_button.onclick = function () { - let fieldstring = ""; - for (let i = 1; i <= parseInt(mowcnewcheckout.otp_length_mo); i++) { - fieldstring += document.querySelector("#digit-" + i).value; - } - jQuery("#mo_otp_token").attr('value', fieldstring); - mo_validate_popup_otp(fieldstring); - }; - } - } else { - $mo("#miniorange_otp_validate_submit,#mo_sec_otp_submit_button").on("click", function (event) { - let fieldstring = $mo("#mo_otp_token").val(); - mo_validate_popup_otp(fieldstring); - }); - } - - // Handle both initial send and resend actions within the popup. - $mo("#miniorange_wc_popup_send_otp_token, #mo_otp_verification_resend, .mo-resend") - // Remove any existing handlers (including from moDefaultPopUp) so that - // block-checkout-specific AJAX behavior is used consistently. - .off("click") - .on("click.moPopupOtp", function (event) { - let requiredFields = $mo('[required]'); - let allFieldsFilled = true; - requiredFields.each(function () { - if (!$mo(this).val().trim()) { - if ($mo(this).attr("id") != "mo_otp_token") { - allFieldsFilled = false; - $mo(this).focus(); - return false; - } - } - }); - - if (!allFieldsFilled) { - $mo('.wc-block-components-checkout-place-order-button').click(); - } else { - $mo('#popup_wc_mo').show(); - let img = "<div class='moloader'></div>"; - jQuery("#mo_message_wc_pop_up").empty().append(img).show(); - $mo(".mo_customer_validation-login-container").show(); - - let user = $mo("#" + mowcnewcheckout.field).val(); - let sendPhone = (mowcnewcheckout.otpType === 'email') ? '' : user; - let sendEmail = (mowcnewcheckout.otpType === 'phone') ? '' : user; - - $mo.ajax({ - url: mowcnewcheckout.siteURL, - type: "POST", - data: { - user_phone: sendPhone, - user_email: sendEmail, - action: mowcnewcheckout.gaction, - security: mowcnewcheckout.nonce, - otpType: mowcnewcheckout.otpType - }, - crossDomain: true, - dataType: "json", - success: function (response) { - if (response.result === "success") { - if (typeof window !== 'undefined') { - window.mo_wc_otp_initialized = true; - } - $mo(".blockUI").hide(); - moWcPopupMessageStripStyles(); - jQuery("#mo_message_wc_pop_up").text(response.message).show(); - $mo(".digit-group input[type='text']").val(""); - $mo("input[name='order_verify']").val(""); - $mo("#popup_wc_mo").show(); - } else { - if (typeof window !== 'undefined') { - window.mo_wc_otp_initialized = false; - } - jQuery("#mo_message_wc_pop_up").empty().append(response.message); - jQuery("#mo_message_wc_pop_up").css({ - // "background-color": "#ffefef", - "color": "#ff5b5b" - }); - $mo(".blockUI").hide(); - } - $mo('button#miniorange_wc_popup_send_otp_token').show().prop('disabled', false).css('opacity', ''); - }, - error: function (xhr, status, error) { - console.error('AJAX Error:', error); - $mo('button#miniorange_wc_popup_send_otp_token').show().prop('disabled', false).css('opacity', ''); - } - }); - - $mo(".close").on("click", function (event) { - $mo("#popup_wc_mo").hide(); - }); - } - if (event && typeof event.preventDefault === "function") { - event.preventDefault(); - } - }); - } else { - $mo(send_verify_otp).insertAfter($mo("#" + mowcnewcheckout.field).parent().parent()); - - $mo(document).on('focus', '#mo_otp_token', function () { - $mo(this).parent().addClass('is-active'); - }); - - $mo(document).on('blur', '#mo_otp_token', function () { - if (!$mo(this).val()) { - $mo(this).parent().removeClass('is-active'); - } - }); - - $mo("#miniorange_otp_token_submit_wc_block_checkout").on("click", function () { - let user = $mo("#" + mowcnewcheckout.field).val(); - let sendPhone = (mowcnewcheckout.otpType === 'email') ? '' : user; - let sendEmail = (mowcnewcheckout.otpType === 'phone') ? '' : user; - let otp = $mo("input[name=phone_verify]"); - let msg_box = $mo("#mo_message"); - - // Sanitize user input to prevent XSS - user = $mo('<div/>').text(user).html(); - - msg_box.empty(); - msg_box.append(img); - msg_box.show(); - - // Safely set window.verifyOTPmessage - if (typeof window !== 'undefined') { - window.verifyOTPmessage = img; - } - - $mo.ajax({ - url: mowcnewcheckout.siteURL, - type: "POST", - data: { - user_phone: sendPhone, - user_email: sendEmail, - action: mowcnewcheckout.gaction, - security: mowcnewcheckout.nonce, - otpType: mowcnewcheckout.otpType - }, - crossDomain: true, - dataType: "json", - success: function (response) { - if ("success" === response.result) { - if (typeof window !== 'undefined') { - delete window.verifyOTPmessage; - } - msg_box.empty(); - msg_box.append(response.message); - msg_box.css({ - "background-color": "#dbfff7", - "color": "#008f6e" - }); - otp.focus(); - $mo("#mo_verify_otp_fields").show(); - $mo("#miniorange_verify_otp_token").show(); - } else { - if (typeof window !== 'undefined') { - window.verifyOTPmessage = response.message; - } - msg_box.empty(); - msg_box.append(response.message); - msg_box.css({ - "background-color": "#ffefef", - "color": "#ff5b5b" - }); - } - }, - error: function (xhr, status, error) { - console.error('AJAX Error:', error); - }, - }); - }); - - $mo("#miniorange_verify_otp_token").on("click", function () { - let user = $mo("#" + mowcnewcheckout.field).val(); - let sendPhone = (mowcnewcheckout.otpType === 'email') ? '' : user; - let sendEmail = (mowcnewcheckout.otpType === 'phone') ? '' : user; - let otp_token = $mo("#mo_otp_token").val(); - let msg_box = $mo("#mo_message"); - - msg_box.empty(); - msg_box.append(img); - msg_box.show(); - - if (typeof window !== 'undefined') { - window.verifyOTPmessage = img; - } - - $mo.ajax({ - url: mowcnewcheckout.siteURL, - type: "POST", - data: { - user_phone: sendPhone, - user_email: sendEmail, - action: mowcnewcheckout.vaction, - security: mowcnewcheckout.nonce, - otpType: mowcnewcheckout.otpType, - otp_token: otp_token - }, - crossDomain: true, - dataType: "json", - success: function (response) { - if ("success" === response.result) { - if (typeof window !== 'undefined') { - delete window.verifyOTPmessage; - } - msg_box.empty(); - msg_box.hide(); - $mo("#mo_verify_otp_fields").hide(); - $mo("#miniorange_verify_otp_token").hide(); - $mo("#miniorange_otp_token_submit_wc_block_checkout").val("Verified ✔"); - $mo("#miniorange_otp_token_submit_wc_block_checkout").show(); - $mo("#miniorange_otp_token_submit_wc_block_checkout").prop("disabled", true); - $mo("#mo_otp_token").val(''); - is_otp_verified(user); - } else { - if (typeof window !== 'undefined') { - window.verifyOTPmessage = response.message; - } - msg_box.empty(); - msg_box.append(response.message); - msg_box.css({ - "background-color": "#ffefef", - "color": "#ff5b5b" - }); - } - }, - error: function (xhr, status, error) { - console.error('AJAX Error:', error); - }, - }); - }); - } - } - - function is_otp_verified(user_detail) { - $mo("#" + mowcnewcheckout.field).on('keydown keyup', function () { - if ($mo("#" + mowcnewcheckout.field).val() !== user_detail) { - $mo("#miniorange_otp_token_submit_wc_block_checkout").val(mowcnewcheckout.buttonText); - $mo("#miniorange_otp_token_submit_wc_block_checkout").removeAttr("disabled"); - } else { - $mo("#miniorange_otp_token_submit_wc_block_checkout").val("Verified ✔"); - $mo("#miniorange_otp_token_submit_wc_block_checkout").prop("disabled", true); - } - }); - } - - function mo_validate_popup_otp(fieldstring) { - let img = "<div class='moloader'></div>"; - jQuery("#mo_message_wc_pop_up").empty().append(img).show(); - - let user = $mo("#" + mowcnewcheckout.field).val(); - let sendPhone = (mowcnewcheckout.otpType === 'email') ? '' : user; - let sendEmail = (mowcnewcheckout.otpType === 'phone') ? '' : user; - - $mo.ajax({ - url: mowcnewcheckout.siteURL, - type: "POST", - data: { - user_phone: sendPhone, - user_email: sendEmail, - action: mowcnewcheckout.vaction, - security: mowcnewcheckout.nonce, - otpType: mowcnewcheckout.otpType, - otp_token: fieldstring - }, - crossDomain: true, - dataType: "json", - success: function (response) { - if (response.result === "success") { - moWcPopupMessageStripStyles(); - jQuery("#mo_message_wc_pop_up").text(response.message).show(); - $mo("#popup_wc_mo").hide(); - $mo('form[name="checkout"]').submit(); - $mo('.wc-block-components-checkout-place-order-button').click(); - } else { - jQuery("#mo_message_wc_pop_up").text(response.message).css({ - // "background-color": "#ffefef", - "color": "#ff5b5b" - }).show(); - } - }, - error: function (xhr, status, error) { - console.error('AJAX Error:', error); - } - }); - } -}); +jQuery(document).ready(function () { + if (typeof jQuery.fn.$mo !== 'function') { + jQuery.fn.$mo = function () { + return this; + }; + } + var $mo = jQuery; + var popupInitialized = false; + + /** WC popup message: plain text only (no inline error/success colors left from spam preventer). */ + function moWcPopupMessageStripStyles() { + var $m = jQuery('#mo_message_wc_pop_up'); + if ($m.length) { + $m.removeAttr('style'); + $m.removeData('mo-osp-error-message'); + } + } + + function check_form_loaded() { + if ($mo('.wc-block-components-address-form__phone input[type="tel"]').length || jQuery(".wc-block-components-text-input input[type=tel]").length) { + // Inject popup markup once the Block Checkout form is present. + if (mowcnewcheckout.popupEnabled && mowcnewcheckout.popupHtml && !popupInitialized) { + mo_add_custom_popup(); + } + + send_and_verify_otp(); + if (mowcnewcheckout.selectivePaymentEnabled) { + check_payment_methods(); + } + } else { + setTimeout(check_form_loaded, 100); + } + } + + check_form_loaded(); + + function mo_add_custom_popup() { + if (popupInitialized || !mowcnewcheckout.popupHtml) { + return; + } + + var $form = jQuery(".wc-block-checkout__form"); + if (!$form.length) { + return; + } + + popupInitialized = true; + + var htmlContent = mowcnewcheckout.popupHtml; + $form.append(htmlContent); + + // Normalize popup markup similar to original inline script. + var $popupForm = jQuery("#mo_validate_form"); + if ($popupForm.length) { + $popupForm.children().appendTo($popupForm.parent()); + $popupForm.remove(); + } + + jQuery('[name="mo_otp_token"]').attr({ id: 'mo_otp_token', name: 'order_verify' }); + + var $oldSubmit = jQuery('[name="miniorange_otp_token_submit"]'); + if ($oldSubmit.length) { + var $newBtn = jQuery('<input>', { + type: 'button', + id: 'miniorange_otp_validate_submit', + class: $oldSubmit.attr('class'), + value: $oldSubmit.attr('value') + }); + $oldSubmit.replaceWith($newBtn); + } + + jQuery('.close').removeAttr('onclick'); + jQuery("#validation_goBack_form, #verification_resend_otp_form, #goBack_choice_otp_form").remove(); + jQuery('a[onclick="mo_otp_verification_resend()"]').attr('id', 'mo_otp_verification_resend').removeAttr('onclick'); + jQuery('.mo_customer_validation-login-container').find('input[type="hidden"]').remove(); + jQuery("#mo_message").remove(); + + attachOtpInputSanitizers(); + } + + function attachOtpInputSanitizers() { + try { + var pattern = /[^a-zA-Z0-9]/g; + if (typeof mowcnewcheckout !== 'undefined' && mowcnewcheckout.popupInputPattern) { + var sanitizedPattern = $mo('<div/>').text(mowcnewcheckout.popupInputPattern).html(); + pattern = new RegExp(sanitizedPattern.replace(/^\/|\/[^\/]*$/g, ''), 'g'); + } + + // Standard OTP input (Default/Streaky) present in checkout popup. + var otpInputs = document.querySelectorAll(".mo_customer_validation-textbox.mo-new-ui-validation-textbox, #mo_otp_token, .otp-streaky-input"); + otpInputs.forEach(function (input) { + input.addEventListener("input", function () { + var originalValue = input.value || ""; + var cleanedValue = originalValue.replace(pattern, ""); + if (originalValue !== cleanedValue) { + input.value = cleanedValue; + } + }); + input.addEventListener("paste", function (e) { + e.preventDefault(); + var pasted = (e.clipboardData || window.clipboardData).getData("text") || ""; + var clean = pasted.replace(pattern, ""); + var start = input.selectionStart || 0; + var end = input.selectionEnd || 0; + var currentValue = input.value || ""; + input.value = currentValue.slice(0, start) + clean + currentValue.slice(end); + if (input.setSelectionRange) { + input.setSelectionRange(start + clean.length, start + clean.length); + } + }); + }); + + // Catchy style individual boxes (keep single char, sanitize). + var catchyInputs = document.querySelectorAll(".digit-group .otp-catchy"); + catchyInputs.forEach(function (box) { + box.setAttribute("maxlength", "1"); + var enforce = function () { + var v = box.value || ""; + var c = v.replace(pattern, ""); + if (c.length > 1) { + c = c.charAt(0); + } + if (v !== c) { + box.value = c; + } + }; + box.addEventListener("input", enforce); + box.addEventListener("paste", function (e) { + e.preventDefault(); + var pasted = (e.clipboardData || window.clipboardData).getData("text") || ""; + var clean = pasted.replace(pattern, ""); + if (clean.length > 1) { + clean = clean.charAt(0); + } + box.value = clean; + }); + }); + } catch (e) { + // Fail silently; sanitizers are a hardening layer. + } + } + + function check_payment_methods() { + let methods = mowcnewcheckout.paymentMethods; + let payment_based_otp = mowcnewcheckout.selectivePaymentEnabled; + + function checkInitialState() { + let hasPaymentMethods = $mo('input[name="radio-control-wc-payment-method-options"]').length > 0 || + $mo('.wc-block-components-payment-methods').length > 0 || + $mo('input[name=payment_method]').length > 0; + + if (hasPaymentMethods) { + toggleSubmitButton(); + } else { + setTimeout(checkInitialState, 100); + } + } + + checkInitialState(); + + setTimeout(function() { + toggleSubmitButton(); + }, 300); + setTimeout(function() { + toggleSubmitButton(); + }, 600); + setTimeout(function() { + toggleSubmitButton(); + }, 1000); + setTimeout(function() { + toggleSubmitButton(); + }, 1500); + + $mo(document).on('click change', 'input[name="radio-control-wc-payment-method-options"], input[name=payment_method]', function () { + toggleSubmitButton(); + }); + + $mo(document).on('wc-blocks-payment-method-selected', function() { + toggleSubmitButton(); + }); + + function toggleSubmitButton() { + let selectedValue = $mo('input[name="radio-control-wc-payment-method-options"]:checked').val(); + + let show_otp_button = false; + + if (!payment_based_otp) { + show_otp_button = true; + } else { + if (selectedValue && methods.hasOwnProperty(selectedValue)) { + show_otp_button = true; + } else { + let blockPaymentMethod = $mo('.wc-block-components-payment-methods input:checked').data('payment-method-id') || + $mo('.wc-block-components-payment-methods input:checked').attr('id'); + if (blockPaymentMethod) { + blockPaymentMethod = blockPaymentMethod.replace('wc-payment-method-', '').replace('payment_method_', ''); + if (methods.hasOwnProperty(blockPaymentMethod)) { + show_otp_button = true; + } + } + + if (!show_otp_button) { + $mo("input[name=payment_method]").each(function () { + let payment = $mo(this).val(); + if ($mo(this).is(':checked') && methods.hasOwnProperty(payment)) { + show_otp_button = true; + return false; + } + }); + } + + if (!show_otp_button) { + let wcPaymentMethod = $mo('input[name="payment_method"]:checked').val(); + if (!wcPaymentMethod) { + let $paymentInputs = $mo('input[name="payment_method"]'); + if ($paymentInputs.length === 1) { + wcPaymentMethod = $paymentInputs.first().val(); + } + } + if (wcPaymentMethod && methods.hasOwnProperty(wcPaymentMethod)) { + show_otp_button = true; + } + } + + if (!show_otp_button) { + let activePaymentMethod = $mo('.wc-block-components-payment-methods .wc-block-components-radio-control__option--checked').find('input').val() || + $mo('.wc-block-components-payment-methods .wc-block-components-radio-control__option--checked').data('value'); + if (activePaymentMethod && methods.hasOwnProperty(activePaymentMethod)) { + show_otp_button = true; + } + } + + if (!show_otp_button && typeof wc !== 'undefined' && wc.wcBlocksData && wc.wcBlocksData.storeApi) { + try { + let storeData = wc.wcBlocksData.storeApi; + if (storeData.paymentMethodData && storeData.paymentMethodData.selectedPaymentMethod) { + let storePaymentMethod = storeData.paymentMethodData.selectedPaymentMethod; + if (methods.hasOwnProperty(storePaymentMethod)) { + show_otp_button = true; + } + } + } catch(e) { + } + } + + if (!show_otp_button) { + $mo('.wc-block-components-payment-methods input[type="radio"]:checked').each(function() { + let payment = $mo(this).val(); + if (payment && methods.hasOwnProperty(payment)) { + show_otp_button = true; + return false; + } + }); + } + + if (!show_otp_button) { + $mo('.wc-block-components-payment-methods input:checked, .wc-block-checkout__payment-methods input:checked').each(function() { + let payment = $mo(this).val() || $mo(this).attr('value'); + if (payment && methods.hasOwnProperty(payment)) { + show_otp_button = true; + return false; + } + }); + } + } + } + + if (mowcnewcheckout.popupEnabled) { + $mo("button#miniorange_wc_popup_send_otp_token").show(); + $mo('.wc-block-components-checkout-place-order-button').hide(); + } else if (show_otp_button) { + $mo("#miniorange_otp_token_submit_wc_block_checkout").show(); + } else { + $mo("#miniorange_otp_token_submit_wc_block_checkout").hide(); + } + } + } + + function send_and_verify_otp() { + let send_verify_otp = "<input type='button' id='miniorange_otp_token_submit_wc_block_checkout' style='width: 100%; padding: 1em; margin: 2% 0%;' class='components-button wc-block-components-button wp-element-button contained' value='" + mowcnewcheckout.buttonText + "'/><div id='mo_message' style='background-color: #f7f6f7; display:none; padding: 1em 2em 1em 3.5em;'></div> <div style='display:none;' id='mo_verify_otp_fields'><div class='wc-block-components-text-input'><input type='text' id='mo_otp_token' aria-label='Enter OTP'/><label for='mo_otp_token'>Enter Verification Code</label></div><input type='button' id='miniorange_verify_otp_token' class='components-button wc-block-components-button wp-element-button contained' style='width: 100%; padding: 1em; margin: 2% 0%;' value='Verify OTP'/></div>"; + let img = "<div class='moloader'></div>"; + + if (mowcnewcheckout.popupEnabled) { + $mo('.wc-block-components-checkout-place-order-button').hide(); + var $wcPopupBtn = $mo('button#miniorange_wc_popup_send_otp_token'); + if ($wcPopupBtn.length === 0) { + $mo('.wc-block-components-checkout-place-order-button').after('<button id="miniorange_wc_popup_send_otp_token" class="wp-element-button" type="button">' + mowcnewcheckout.buttonText + '</button>'); + } else { + $wcPopupBtn.show().prop('disabled', false).css('opacity', ''); + } + + if (jQuery('.otp-catchy-box').length > 0) { + jQuery(".digit-group input.otp-catchy").each(function () { + jQuery(this).attr("maxlength", "1"); + }).on("keyup", function (event) { + let parent = jQuery(this).parent(); + if (event.keyCode === 8 || event.keyCode === 37) { + let prevId = jQuery(this).data("previous"); + if (prevId) { + jQuery("#" + prevId).select(); + } + } else { + let nextId = jQuery(this).data("next"); + if (nextId) { + jQuery("#" + nextId).select(); + } + } + }); + + let mo_validate_button = document.getElementById("mo_sec_otp_submit_button"); + if (mo_validate_button) { + mo_validate_button.onclick = function () { + let fieldstring = ""; + for (let i = 1; i <= parseInt(mowcnewcheckout.otp_length_mo); i++) { + fieldstring += document.querySelector("#digit-" + i).value; + } + jQuery("#mo_otp_token").attr('value', fieldstring); + mo_validate_popup_otp(fieldstring); + }; + } + } else { + $mo("#miniorange_otp_validate_submit,#mo_sec_otp_submit_button").on("click", function (event) { + let fieldstring = $mo("#mo_otp_token").val(); + mo_validate_popup_otp(fieldstring); + }); + } + + // Handle both initial send and resend actions within the popup. + $mo("#miniorange_wc_popup_send_otp_token, #mo_otp_verification_resend, .mo-resend") + // Remove any existing handlers (including from moDefaultPopUp) so that + // block-checkout-specific AJAX behavior is used consistently. + .off("click") + .on("click.moPopupOtp", function (event) { + let requiredFields = $mo('[required]'); + let allFieldsFilled = true; + requiredFields.each(function () { + if (!$mo(this).val().trim()) { + if ($mo(this).attr("id") != "mo_otp_token") { + allFieldsFilled = false; + $mo(this).focus(); + return false; + } + } + }); + + if (!allFieldsFilled) { + $mo('.wc-block-components-checkout-place-order-button').click(); + } else { + $mo('#popup_wc_mo').show(); + let img = "<div class='moloader'></div>"; + jQuery("#mo_message_wc_pop_up").empty().append(img).show(); + $mo(".mo_customer_validation-login-container").show(); + + let user = $mo("#" + mowcnewcheckout.field).val(); + let sendPhone = (mowcnewcheckout.otpType === 'email') ? '' : user; + let sendEmail = (mowcnewcheckout.otpType === 'phone') ? '' : user; + + $mo.ajax({ + url: mowcnewcheckout.siteURL, + type: "POST", + data: { + user_phone: sendPhone, + user_email: sendEmail, + action: mowcnewcheckout.gaction, + security: mowcnewcheckout.nonce, + otpType: mowcnewcheckout.otpType + }, + crossDomain: true, + dataType: "json", + success: function (response) { + if (response.result === "success") { + if (typeof window !== 'undefined') { + window.mo_wc_otp_initialized = true; + } + $mo(".blockUI").hide(); + moWcPopupMessageStripStyles(); + jQuery("#mo_message_wc_pop_up").text(response.message).show(); + $mo(".digit-group input[type='text']").val(""); + $mo("input[name='order_verify']").val(""); + $mo("#popup_wc_mo").show(); + } else { + if (typeof window !== 'undefined') { + window.mo_wc_otp_initialized = false; + } + jQuery("#mo_message_wc_pop_up").empty().append(response.message); + jQuery("#mo_message_wc_pop_up").css({ + // "background-color": "#ffefef", + "color": "#ff5b5b" + }); + $mo(".blockUI").hide(); + } + $mo('button#miniorange_wc_popup_send_otp_token').show().prop('disabled', false).css('opacity', ''); + }, + error: function (xhr, status, error) { + console.error('AJAX Error:', error); + $mo('button#miniorange_wc_popup_send_otp_token').show().prop('disabled', false).css('opacity', ''); + } + }); + + $mo(".close").on("click", function (event) { + $mo("#popup_wc_mo").hide(); + }); + } + if (event && typeof event.preventDefault === "function") { + event.preventDefault(); + } + }); + } else { + $mo(send_verify_otp).insertAfter($mo("#" + mowcnewcheckout.field).parent().parent()); + + $mo(document).on('focus', '#mo_otp_token', function () { + $mo(this).parent().addClass('is-active'); + }); + + $mo(document).on('blur', '#mo_otp_token', function () { + if (!$mo(this).val()) { + $mo(this).parent().removeClass('is-active'); + } + }); + + $mo("#miniorange_otp_token_submit_wc_block_checkout").on("click", function () { + let user = $mo("#" + mowcnewcheckout.field).val(); + let sendPhone = (mowcnewcheckout.otpType === 'email') ? '' : user; + let sendEmail = (mowcnewcheckout.otpType === 'phone') ? '' : user; + let otp = $mo("input[name=phone_verify]"); + let msg_box = $mo("#mo_message"); + + // Sanitize user input to prevent XSS + user = $mo('<div/>').text(user).html(); + + msg_box.empty(); + msg_box.append(img); + msg_box.show(); + + // Safely set window.verifyOTPmessage + if (typeof window !== 'undefined') { + window.verifyOTPmessage = img; + } + + $mo.ajax({ + url: mowcnewcheckout.siteURL, + type: "POST", + data: { + user_phone: sendPhone, + user_email: sendEmail, + action: mowcnewcheckout.gaction, + security: mowcnewcheckout.nonce, + otpType: mowcnewcheckout.otpType + }, + crossDomain: true, + dataType: "json", + success: function (response) { + if ("success" === response.result) { + if (typeof window !== 'undefined') { + delete window.verifyOTPmessage; + } + msg_box.empty(); + msg_box.append(response.message); + msg_box.css({ + "background-color": "#dbfff7", + "color": "#008f6e" + }); + otp.focus(); + $mo("#mo_verify_otp_fields").show(); + $mo("#miniorange_verify_otp_token").show(); + } else { + if (typeof window !== 'undefined') { + window.verifyOTPmessage = response.message; + } + msg_box.empty(); + msg_box.append(response.message); + msg_box.css({ + "background-color": "#ffefef", + "color": "#ff5b5b" + }); + } + }, + error: function (xhr, status, error) { + console.error('AJAX Error:', error); + }, + }); + }); + + $mo("#miniorange_verify_otp_token").on("click", function () { + let user = $mo("#" + mowcnewcheckout.field).val(); + let sendPhone = (mowcnewcheckout.otpType === 'email') ? '' : user; + let sendEmail = (mowcnewcheckout.otpType === 'phone') ? '' : user; + let otp_token = $mo("#mo_otp_token").val(); + let msg_box = $mo("#mo_message"); + + msg_box.empty(); + msg_box.append(img); + msg_box.show(); + + if (typeof window !== 'undefined') { + window.verifyOTPmessage = img; + } + + $mo.ajax({ + url: mowcnewcheckout.siteURL, + type: "POST", + data: { + user_phone: sendPhone, + user_email: sendEmail, + action: mowcnewcheckout.vaction, + security: mowcnewcheckout.nonce, + otpType: mowcnewcheckout.otpType, + otp_token: otp_token + }, + crossDomain: true, + dataType: "json", + success: function (response) { + if ("success" === response.result) { + if (typeof window !== 'undefined') { + delete window.verifyOTPmessage; + } + msg_box.empty(); + msg_box.hide(); + $mo("#mo_verify_otp_fields").hide(); + $mo("#miniorange_verify_otp_token").hide(); + $mo("#miniorange_otp_token_submit_wc_block_checkout").val("Verified ✔"); + $mo("#miniorange_otp_token_submit_wc_block_checkout").show(); + $mo("#miniorange_otp_token_submit_wc_block_checkout").prop("disabled", true); + $mo("#mo_otp_token").val(''); + is_otp_verified(user); + } else { + if (typeof window !== 'undefined') { + window.verifyOTPmessage = response.message; + } + msg_box.empty(); + msg_box.append(response.message); + msg_box.css({ + "background-color": "#ffefef", + "color": "#ff5b5b" + }); + } + }, + error: function (xhr, status, error) { + console.error('AJAX Error:', error); + }, + }); + }); + } + } + + function is_otp_verified(user_detail) { + $mo("#" + mowcnewcheckout.field).on('keydown keyup', function () { + if ($mo("#" + mowcnewcheckout.field).val() !== user_detail) { + $mo("#miniorange_otp_token_submit_wc_block_checkout").val(mowcnewcheckout.buttonText); + $mo("#miniorange_otp_token_submit_wc_block_checkout").removeAttr("disabled"); + } else { + $mo("#miniorange_otp_token_submit_wc_block_checkout").val("Verified ✔"); + $mo("#miniorange_otp_token_submit_wc_block_checkout").prop("disabled", true); + } + }); + } + + function mo_validate_popup_otp(fieldstring) { + let img = "<div class='moloader'></div>"; + jQuery("#mo_message_wc_pop_up").empty().append(img).show(); + + let user = $mo("#" + mowcnewcheckout.field).val(); + let sendPhone = (mowcnewcheckout.otpType === 'email') ? '' : user; + let sendEmail = (mowcnewcheckout.otpType === 'phone') ? '' : user; + + $mo.ajax({ + url: mowcnewcheckout.siteURL, + type: "POST", + data: { + user_phone: sendPhone, + user_email: sendEmail, + action: mowcnewcheckout.vaction, + security: mowcnewcheckout.nonce, + otpType: mowcnewcheckout.otpType, + otp_token: fieldstring + }, + crossDomain: true, + dataType: "json", + success: function (response) { + if (response.result === "success") { + moWcPopupMessageStripStyles(); + jQuery("#mo_message_wc_pop_up").text(response.message).show(); + $mo("#popup_wc_mo").hide(); + $mo('form[name="checkout"]').submit(); + $mo('.wc-block-components-checkout-place-order-button').click(); + } else { + jQuery("#mo_message_wc_pop_up").text(response.message).css({ + // "background-color": "#ffefef", + "color": "#ff5b5b" + }).show(); + } + }, + error: function (xhr, status, error) { + console.error('AJAX Error:', error); + } + }); + } +}); @@ -1195,27 +1195,73 @@ }); }); + function moMakeWhatsappLink( id, extraClass, textContent ) { + var a = document.createElement( 'a' ); + a.id = id; + a.className = 'mo-whatsapp-links' + ( extraClass ? ' ' + extraClass : '' ); + a.href = moadminsettings.whatsapp_tab; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + a.textContent = textContent; + return a; + } + let whatsapp_settings; if (!moadminsettings.whatsapp_file) { - whatsapp_settings = '<div id="mo_free_whatsapp_html" class="mo-whatsapp-links mo_whatsapp_marketing">[ <a id="mo_whatsapp_not_enabled" href="' + moadminsettings.whatsapp_tab + '" target="_blank">' + moadminsettings.whatsapp_disabled_text + ' </a>\ - <span class="tooltip mo_whatsapp_tooltip">\ - <svg width="18" height="18" viewBox="0 0 24 24" fill="none">\ - <g id="d4a43e0162b45f718f49244b403ea8f4">\ - <g id="4ea4c3dca364b4cff4fba75ac98abb38">\ - <g id="2413972edc07f152c2356073861cb269">\ - <path id="2deabe5f8681ff270d3f37797985a977" d="M20.8007 20.5644H3.19925C2.94954 20.5644 2.73449 20.3887 2.68487 20.144L0.194867 7.94109C0.153118 7.73681 0.236091 7.52728 0.406503 7.40702C0.576651 7.28649 0.801941 7.27862 0.980492 7.38627L7.69847 11.4354L11.5297 3.72677C11.6177 3.54979 11.7978 3.43688 11.9955 3.43531C12.1817 3.43452 12.3749 3.54323 12.466 3.71889L16.4244 11.3598L23.0197 7.38654C23.1985 7.27888 23.4233 7.28702 23.5937 7.40728C23.7641 7.52754 23.8471 7.73707 23.8056 7.94136L21.3156 20.1443C21.2652 20.3887 21.0501 20.5644 20.8007 20.5644Z" fill="orange"></path>\ - </g>\ + var waDiv = document.createElement( 'div' ); + waDiv.id = 'mo_free_whatsapp_html'; + waDiv.className = 'mo-whatsapp-links mo_whatsapp_marketing'; + var waLink = moMakeWhatsappLink( 'mo_whatsapp_not_enabled', '', moadminsettings.whatsapp_disabled_text + ' ' ); + waDiv.appendChild( document.createTextNode( '[ ' ) ); + waDiv.appendChild( waLink ); + + var tooltipSpan = document.createElement( 'span' ); + tooltipSpan.className = 'tooltip mo_whatsapp_tooltip'; + tooltipSpan.innerHTML = '\ + <svg width="18" height="18" viewBox="0 0 24 24" fill="none">\ + <g id="d4a43e0162b45f718f49244b403ea8f4">\ + <g id="4ea4c3dca364b4cff4fba75ac98abb38">\ + <g id="2413972edc07f152c2356073861cb269">\ + <path id="2deabe5f8681ff270d3f37797985a977" d="M20.8007 20.5644H3.19925C2.94954 20.5644 2.73449 20.3887 2.68487 20.144L0.194867 7.94109C0.153118 7.73681 0.236091 7.52728 0.406503 7.40702C0.576651 7.28649 0.801941 7.27862 0.980492 7.38627L7.69847 11.4354L11.5297 3.72677C11.6177 3.54979 11.7978 3.43688 11.9955 3.43531C12.1817 3.43452 12.3749 3.54323 12.466 3.71889L16.4244 11.3598L23.0197 7.38654C23.1985 7.27888 23.4233 7.28702 23.5937 7.40728C23.7641 7.52754 23.8471 7.73707 23.8056 7.94136L21.3156 20.1443C21.2652 20.3887 21.0501 20.5644 20.8007 20.5644Z" fill="orange"></path>\ </g>\ </g>\ - </svg>\ - <span class="tooltiptext prem_form_tooltip" style="transform:translateY(-7%);">\ - <span class="header prem_form_header"><b>WhatsApp + Twilio Gateway Plan Feature</b></span>\ - <span class="body">To use OTPs over WhatsApp, upgrade to the WhatsApp + Twilio Gateway Plan.<br>Check <a class="font-semibold text-yellow-500" href=" ' + moadminsettings.pricing_plan_url + '" target="_blank">Licensing Tab</a> to learn more.</span>\ - </span>\ ]</div>'; + </g>\ + </svg>'; + + var tooltipText = document.createElement( 'span' ); + tooltipText.className = 'tooltiptext prem_form_tooltip'; + tooltipText.setAttribute( 'style', 'transform:translateY(-7%);' ); + + var tooltipHeader = document.createElement( 'span' ); + tooltipHeader.className = 'header prem_form_header'; + tooltipHeader.innerHTML = '<b>WhatsApp + Twilio Gateway Plan Feature</b>'; + + var tooltipBody = document.createElement( 'span' ); + tooltipBody.className = 'body'; + + var pricingLink = document.createElement( 'a' ); + pricingLink.className = 'font-semibold text-yellow-500'; + pricingLink.href = moadminsettings.pricing_plan_url || ''; + pricingLink.target = '_blank'; + pricingLink.textContent = 'Licensing Tab'; + + tooltipBody.appendChild( document.createTextNode( 'To use OTPs over WhatsApp, upgrade to the WhatsApp + Twilio Gateway Plan.' ) ); + tooltipBody.appendChild( document.createElement( 'br' ) ); + tooltipBody.appendChild( document.createTextNode( 'Check ' ) ); + tooltipBody.appendChild( pricingLink ); + tooltipBody.appendChild( document.createTextNode( ' to learn more.' ) ); + + tooltipText.appendChild( tooltipHeader ); + tooltipText.appendChild( tooltipBody ); + tooltipSpan.appendChild( tooltipText ); + waDiv.appendChild( tooltipSpan ); + waDiv.appendChild( document.createTextNode( ' ]' ) ); + + whatsapp_settings = waDiv; } else if( moadminsettings.iswhatsappenable ){ whatsapp_settings = '<span id="mo_whatsapp_enabled" class="addon-table-list-status mo-whatsapp-links">[ '+ moadminsettings.whatsapp_enabled_text +' ]</span>'; } else { - whatsapp_settings = '<a id="mo_whatsapp_not_enabled" class="mo-whatsapp-links" href="' + moadminsettings.whatsapp_tab + '" target="_blank">[ ' + moadminsettings.whatsapp_disabled_text + ' ]</a>'; + whatsapp_settings = moMakeWhatsappLink( 'mo_whatsapp_not_enabled', '', '[ ' + moadminsettings.whatsapp_disabled_text + ' ]' ); } //Exceptional forms: @@ -17,7 +17,7 @@ * @param pointerNumber int value */ function startTour(pointerNumber) { - if (!moTour.tourData) return; + if (!moTour.tourData || !moTour.tourData.length) return; if (Object.keys(moTour.currentPage).length > 1) return; @@ -31,6 +31,12 @@ function createCard(pointerNumber) { let tourElement = moTour.tourData[pointerNumber]; + if (!tourElement) { + resetTour(); + tourComplete(); + return; + } + if ( !$mo("#" + tourElement.targetE).is(":visible") && (pointerNumber !== 0 || tourElement.targetE !== "") @@ -3,7 +3,7 @@ * Plugin Name: miniOrange OTP Login, Verification and SMS Notifications * Plugin URI: http://miniorange.com * Description: Email & SMS OTP verification on 60+ forms, SMS notifications for WooCommerce, passwordless login, Login with phone, support for external OTP gateways. - * Version: 5.5.1 + * Version: 5.5.2 * Author: miniOrange * Author URI: https://miniorange.com * Text Domain: miniorange-otp-verification @@ -5,7 +5,7 @@ Requires at least: 3.5 Tested up to: 7.0 Requires PHP: 5.3.0 -Stable tag: 5.5.1 +Stable tag: 5.5.2 License: Expat License URI: https://plugins.miniorange.com/mit-license OTP Verification via Email/SMS/WhatsApp,SMS Notifications for WooCommerce,OTP Login with Phone,PasswordLess Login.Custom Gateway for OTP Verification @@ -211,6 +211,9 @@ 8. OTP Verification Plugin Settings == Changelog == += 5.5.2 = +* Introdcing abilites. +* Login form fixes and security fixes. = 5.5.1 = * Session-transient related bug fixes. * Spam preventor addon bug fixes. @@ -306,6 +309,9 @@ == Upgrade Notice == += 5.5.2 = +* Introdcing abilites. +* Login form fixes and security fixes. = 5.5.1 = * Session-transient related bug fixes. * Spam preventor addon bug fixes.
Exploit Outline
1. Identify a page on the target site using the Ultimate Member password reset form (e.g., /password-reset/). 2. View the page source or use a browser console to extract the 'form_nonce' value from the globally localized 'moumprvar' JavaScript object. 3. Prepare a POST request to the same password reset page. 4. Include the following parameters in the request body: 'username_b' (set to the target administrator's username), 'form_nonce' (the value extracted in step 2), and 'um_for_reset_password=1'. 5. Submit the request and monitor the HTTP response without following redirects. 6. Capture the 'Location' header from the 302 Found response, which will contain a complete password reset URL for the targeted administrator (containing the 'rp_key' and 'rp_login' parameters). 7. Navigate to the captured URL to set a new password for the administrator account.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.