Plugin Directory

Changeset 3798396


Ignore:
Timestamp:
11/12/2026 01:24:47 PM (less than one hour ago)
Author:
alexopen
Message:

Update trunk to 1.4.6

Location:
smashballoon-wpchat-livechat-customer-support/trunk
Files:
2 added
11 edited

Legend:

Unmodified
Added
Removed
  • smashballoon-wpchat-livechat-customer-support/trunk/readme.txt

    r3760121 r3798396  
    77Tested up to: 7.1
    88Requires PHP: 8.0
    9 Stable tag: 1.4.5
     9Stable tag: 1.4.6
    1010License: GPLv2 or later
    1111License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    257257== Changelog ==
    258258
     259= 1.4.6 =
     260* Tweak: Connecting Smart Search from the emailed link now opens a "Connect Smart Search" screen in WPChat, so you confirm before Smart Search connects.
     261* Tweak: Smart Search now limits how many FAQ searches one visitor can make, so a single visitor can't use up your site's Smart Search tokens. Once a limit is reached, search falls back to keyword matching.
     262* Fix: The Smart Search email-verification window now lasts a full 24 hours on sites not set to UTC.
     263* Fix: Plugin security hardening.
     264
    259265= 1.4.5 =
    260266* New: Added a "Visitor interaction analytics" setting in WPChat > Settings to turn chat analytics off, plus the `wpchat_analytics_enabled` filter so consent plugins can turn analytics off per visitor, and the `wpchat_smart_search_enabled` filter to keep FAQ search on your own site.
  • smashballoon-wpchat-livechat-customer-support/trunk/src/Common/RestAPI/SettingsEndpoint.php

    r3760121 r3798396  
    55use SmashBalloon\WPChat\Common\Services\SettingsService;
    66use SmashBalloon\WPChat\Common\Services\PrivateSettingsService;
     7use SmashBalloon\WPChat\Common\Services\SmartSearchManagerService;
    78use SmashBalloon\WPChat\Common\Services\ApiService;
    89use WP_REST_Request;
    … …  
    3940
    4041        /**
     42         * The Smart Search manager, which decides whether a claim is recent.
     43         *
     44         * @var SmartSearchManagerService
     45         */
     46        private $smartSearchManager;
     47
     48        /**
    4149         * Constructor.
    4250         *
    43          * @param SettingsService $settingsService The settings service.
    44          * @param PrivateSettingsService $privateSettingsService The private settings service.
    45          */
    46         public function __construct(SettingsService $settingsService, PrivateSettingsService $privateSettingsService) {
     51         * @param SettingsService           $settingsService        The settings service.
     52         * @param PrivateSettingsService    $privateSettingsService The private settings service.
     53         * @param SmartSearchManagerService $smartSearchManager     The Smart Search manager.
     54         */
     55        public function __construct(
     56                SettingsService $settingsService,
     57                PrivateSettingsService $privateSettingsService,
     58                SmartSearchManagerService $smartSearchManager
     59        ) {
    4760                $this->settingsService = $settingsService;
    4861                $this->privateSettingsService = $privateSettingsService;
     62                $this->smartSearchManager = $smartSearchManager;
    4963        }
    5064
    … …  
    95109         * Get all settings.
    96110         *
     111         * The route is public because the front-end widget reads it. The Smart Search
     112         * account fields (token state, usage and the newsletter email that identifies the
     113         * site to the WPChat API) come from private settings and go to admins only.
     114         *
    97115         * @return WP_REST_Response
    98116         */
    99117        public function getSettings() {
    100118                $settings = $this->settingsService->getAllSettings();
     119
     120                if (!$this->checkPermission()) {
     121                        return rest_ensure_response($settings);
     122                }
    101123
    102124                // Add a flag to indicate if an access token exists
    … …  
    370392        private function sanitizeSettingsFromRequest(array $settingsData): array {
    371393                $sanitizedSettings = array();
     394                $settingsData      = array_diff_key( $settingsData, array_flip( SettingsService::ADMIN_ONLY_KEYS ) );
    372395
    373396                foreach ( $settingsData as $key => $value ) {
    … …  
    481504         */
    482505        private function isNewsletterWithin24Hours(): bool {
    483                 $isSubscribed = (bool)$this->privateSettingsService->getSetting( 'newsletter_subscribed', false );
    484                 $subscriptionDate = $this->privateSettingsService->getSetting( 'newsletter_subscription_date', '' );
    485 
    486                 if ( ! $isSubscribed || empty( $subscriptionDate ) ) {
    487                         return false;
    488                 }
    489 
    490                 try {
    491                         $subscriptionTime = new \DateTime( $subscriptionDate );
    492                         $now = new \DateTime();
    493                         $hoursDiff = ($now->getTimestamp() - $subscriptionTime->getTimestamp()) / 3600;
    494 
    495                         return $hoursDiff < 24;
    496                 } catch ( \Exception $e ) {
    497                         return false;
    498                 }
     506                // The same check as the Connect screen: the claim date is stored in the site's
     507                // timezone, so reading it as UTC is off by the site's offset (SMASH-2676).
     508                return $this->smartSearchManager->hasRecentClaimOffer();
    499509        }
    500510}
  • smashballoon-wpchat-livechat-customer-support/trunk/src/Common/RestAPI/SmartSearchEndpoint.php

    r3376414 r3798396  
    5252                                        'methods'             => \WP_REST_Server::CREATABLE, // POST
    5353                                        'callback'            => array($this, 'claimOffer'),
    54                                         'permission_callback' => array($this, 'checkPublicPermission'),
     54                                        // Admin only: the claim registers the site with the WPChat API and
     55                                        // rewrites the site's private settings, so the public widget nonce is not enough.
     56                                        'permission_callback' => array($this, 'checkPermission'),
    5557                                        'args'                => array(
    5658                                                'email' => array(
  • smashballoon-wpchat-livechat-customer-support/trunk/src/Common/Services/AdminPagesService.php

    r3584324 r3798396  
    44
    55if ( ! defined( 'ABSPATH' ) ) {
    6 exit;
     6        exit;
    77}
    88
    … …  
    2323 * @var SettingsService
    2424 */
    25 private $settingsService;
     25        private $settingsService;
    2626
    2727/**
    … …  
    3030 * @var PrivateSettingsService
    3131 */
    32 private $privateSettingsService;
     32        private $privateSettingsService;
    3333
    3434/**
    … …  
    3737 * @var GateInterface
    3838 */
    39 private GateInterface $gate;
    40 
    41 /**
    42  * Constructor.
    43  *
    44  * @param SettingsService        $settingsService The settings service.
    45  * @param PrivateSettingsService $privateSettingsService The private settings service.
    46  * @param GateInterface          $gate The gate service.
    47  */
    48 public function __construct( SettingsService $settingsService, PrivateSettingsService $privateSettingsService, GateInterface $gate ) {
    49     $this->settingsService        = $settingsService;
    50     $this->privateSettingsService = $privateSettingsService;
    51     $this->gate                   = $gate;
    52 }
     39        private GateInterface $gate;
     40
     41        /**
     42         * Nonce action for saving the access token from the verification email.
     43         */
     44        public const CONNECT_TOKEN_NONCE_ACTION = 'wpchat_connect_access_token';
     45
     46        /**
     47         * Per-user transient holding a token from the verification link until the admin confirms it.
     48         */
     49        public const PENDING_TOKEN_TRANSIENT = 'wpchat_pending_api_token_';
     50
     51        /**
     52         * Smart Search manager, for the record of this site's own claim.
     53         *
     54         * @var SmartSearchManagerService
     55         */
     56        private SmartSearchManagerService $smartSearchManager;
     57
     58        /**
     59         * Constructor.
     60         *
     61         * @param SettingsService           $settingsService        The settings service.
     62         * @param PrivateSettingsService    $privateSettingsService The private settings service.
     63         * @param GateInterface             $gate                   The gate service.
     64         * @param SmartSearchManagerService $smartSearchManager     The Smart Search manager.
     65         */
     66        public function __construct(
     67                SettingsService $settingsService,
     68                PrivateSettingsService $privateSettingsService,
     69                GateInterface $gate,
     70                SmartSearchManagerService $smartSearchManager
     71        ) {
     72                $this->settingsService = $settingsService;
     73                $this->privateSettingsService = $privateSettingsService;
     74                $this->gate = $gate;
     75                $this->smartSearchManager = $smartSearchManager;
     76        }
    5377
    5478/**
    … …  
    5781 * @inheritDoc
    5882 */
    59 public function register(): void {
    60     add_action( 'admin_menu', array($this, 'addAdminMenu') );
    61     add_action( 'admin_init', array($this, 'handleAccessToken') );
    62     add_action( 'in_admin_header', array($this, 'removeAdminNotices') );
    63     add_filter( 'user_has_cap', array($this, 'filterUserCapabilities'), 10, 4 );
    64     add_filter( 'members_get_capabilities', array($this, 'registerMembersCapabilities') );
    65 }
     83        public function register(): void {
     84                add_action( 'admin_menu', array($this, 'addAdminMenu') );
     85                add_action( 'admin_init', array($this, 'handleAccessToken') );
     86                add_action('load-toplevel_page_wp-chat', array($this, 'captureAccessTokenFromUrl'));
     87                add_action( 'in_admin_header', array($this, 'removeAdminNotices') );
     88                add_filter( 'user_has_cap', array($this, 'filterUserCapabilities'), 10, 4 );
     89                add_filter( 'members_get_capabilities', array($this, 'registerMembersCapabilities') );
     90        }
    6691
    6792/**
    … …  
    7095 * @return void
    7196 **/
    72 public function addAdminMenu() {
    73     $icon_url = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEwLjEwODQgMi4wMDUxOUMxMy4zNjIgMi4wMDUxOSAxNiA0LjY0MzIxIDE2IDcuODk2NzlDMTYgMTEuMjI3NiAxMy4yNjIyIDEzLjgzMyAxMC4wNDEgMTMuNzg5NEwwIDE0LjMyNTVMMS4xMzE4NCAxMC44ODMxQzAuNDE4NzkxIDkuOTQ4MDMgMCA4Ljc4Mjg1IDAgNy41MzI1M0M2LjU1NDE0ZS0wNSA0LjQ4MDEyIDIuNDc0OTIgMi4wMDUxOSA1LjUyNzM0IDIuMDA1MTlIMTAuMTA4NFpNNC4zODA4NiA3LjU5MzA4VjcuNjU2NTZDNC4zODA5MiA5LjQ3OTkxIDUuODU5MjUgMTAuOTU4MyA3LjY4MjYyIDEwLjk1ODNDOS41MDU5IDEwLjk1ODIgMTAuOTg0MyA5LjQ3OTg1IDEwLjk4NDQgNy42NTY1NlY3LjU5MzA4SDkuMzMzMDEgVjcuNjU2NTZDOS4zMzI5NSA4LjU2ODE0IDguNTk0MiA5LjMwNjg0IDcuNjgyNjIgOS4zMDY5NUM2Ljc3MDk1IDkuMzA2OTUgNi4wMzEzMSA4LjU2ODIxIDYuMDMxMjUgNy42NTY1NlY3LjU5MzA4SDQuMzgwODZaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4=';
    74 
    75     $icon_url = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEwLjEwODQgMi4wMDUxOUMxMy4zNjIgMi4wMDUxOSAxNiA0LjY0MzIxIDE2IDcuODk2NzlDMTYgMTEuMjI3NiAxMy4yNjIyIDEzLjgzMyAxMC4wNDEgMTMuNzg5NEwwIDE0LjMyNTVMMS4xMzE4NCAxMC44ODMxQzAuNDE4NzkxIDkuOTQ4MDMgMCA4Ljc4Mjg1IDAgNy41MzI1M0M2LjU1NDE0ZS0wNSA0LjQ4MDEyIDIuNDc0OTIgMi4wMDUxOSA1LjUyNzM0IDIuMDA1MTlIMTAuMTA4NFpNNC4zODA4NiA3LjU5MzA4VjcuNjU2NTZDNC4zODA5MiA5LjQ3OTkxIDUuODU5MjUgMTAuOTU4MyA3LjY4MjYyIDEwLjk1ODNDOS41MDU5IDEwLjk1ODIgMTAuOTg0MyA5LjQ3OTg1IDEwLjk4NDQgNy42NTY1NlY3LjU5MzA4SDkuMzMzMDEgVjcuNjU2NTZDOS4zMzI5NSA4LjU2ODE0IDguNTk0MiA5LjMwNjg0IDcuNjgyNjIgOS4zMDY5NUM2Ljc3MDk1IDkuMzA2OTUgNi4wMzEzMSA4LjU2ODIxIDYuMDMxMjUgNy42NTY1NlY3LjU5MzA4SDQuMzgwODZaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4=';
    76 
    77 add_menu_page(
    78     __( 'WPChat', 'smashballoon-wpchat-livechat-customer-support' ),
    79     __( 'WPChat', 'smashballoon-wpchat-livechat-customer-support' ),
    80     'wpchat_admin',
    81     'wp-chat',
    82     array($this, 'renderAdminPage'),
    83     $icon_url,
    84     26
    85 );
    86 
    87     // Add Overview as first submenu item
    88 add_submenu_page(
    89     'wp-chat',
    90     __( 'Overview', 'smashballoon-wpchat-livechat-customer-support' ),
    91     __( 'Overview', 'smashballoon-wpchat-livechat-customer-support' ),
    92     'wpchat_admin',
    93     'wp-chat',
    94     array($this, 'renderAdminPage'),
    95 );
    96 
    97     $settings         = $this->settingsService->getAllSettings();
    98     $onboardingStatus = $settings['onboardingStatus'] ?? false;
    99 
    100     if ( $onboardingStatus ) {
    101 add_submenu_page(
     97        public function addAdminMenu() {
     98                $icon_url = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEwLjEwODQgMi4wMDUxOUMxMy4zNjIgMi4wMDUxOSAxNiA0LjY0MzIxIDE2IDcuODk2NzlDMTYgMTEuMjI3NiAxMy4yNjIyIDEzLjgzMyAxMC4wNDEgMTMuNzg5NEwwIDE0LjMyNTVMMS4xMzE4NCAxMC44ODMxQzAuNDE4NzkxIDkuOTQ4MDMgMCA4Ljc4Mjg1IDAgNy41MzI1M0M2LjU1NDE0ZS0wNSA0LjQ4MDEyIDIuNDc0OTIgMi4wMDUxOSA1LjUyNzM0IDIuMDA1MTlIMTAuMTA4NFpNNC4zODA4NiA3LjU5MzA4VjcuNjU2NTZDNC4zODA5MiA5LjQ3OTkxIDUuODU5MjUgMTAuOTU4MyA3LjY4MjYyIDEwLjk1ODNDOS41MDU5IDEwLjk1ODIgMTAuOTg0MyA5LjQ3OTg1IDEwLjk4NDQgNy42NTY1NlY3LjU5MzA4SDkuMzMzMDEgVjcuNjU2NTZDOS4zMzI5NSA4LjU2ODE0IDguNTk0MiA5LjMwNjg0IDcuNjgyNjIgOS4zMDY5NUM2Ljc3MDk1IDkuMzA2OTUgNi4wMzEzMSA4LjU2ODIxIDYuMDMxMjUgNy42NTY1NlY3LjU5MzA4SDQuMzgwODZaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4=';
     99
     100                $icon_url = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEwLjEwODQgMi4wMDUxOUMxMy4zNjIgMi4wMDUxOSAxNiA0LjY0MzIxIDE2IDcuODk2NzlDMTYgMTEuMjI3NiAxMy4yNjIyIDEzLjgzMyAxMC4wNDEgMTMuNzg5NEwwIDE0LjMyNTVMMS4xMzE4NCAxMC44ODMxQzAuNDE4NzkxIDkuOTQ4MDMgMCA4Ljc4Mjg1IDAgNy41MzI1M0M2LjU1NDE0ZS0wNSA0LjQ4MDEyIDIuNDc0OTIgMi4wMDUxOSA1LjUyNzM0IDIuMDA1MTlIMTAuMTA4NFpNNC4zODA4NiA3LjU5MzA4VjcuNjU2NTZDNC4zODA5MiA5LjQ3OTkxIDUuODU5MjUgMTAuOTU4MyA3LjY4MjYyIDEwLjk1ODNDOS41MDU5IDEwLjk1ODIgMTAuOTg0MyA5LjQ3OTg1IDEwLjk4NDQgNy42NTY1NlY3LjU5MzA4SDkuMzMzMDEgVjcuNjU2NTZDOS4zMzI5NSA4LjU2ODE0IDguNTk0MiA5LjMwNjg0IDcuNjgyNjIgOS4zMDY5NUM2Ljc3MDk1IDkuMzA2OTUgNi4wMzEzMSA4LjU2ODIxIDYuMDMxMjUgNy42NTY1NlY3LjU5MzA4SDQuMzgwODZaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4=';
     101
     102                add_menu_page(
     103                __( 'WPChat', 'smashballoon-wpchat-livechat-customer-support' ),
     104                __( 'WPChat', 'smashballoon-wpchat-livechat-customer-support' ),
     105                'wpchat_admin',
     106                'wp-chat',
     107                array($this, 'renderAdminPage'),
     108                $icon_url,
     109                26
     110                );
     111
     112                // Add Overview as first submenu item
     113                add_submenu_page(
     114                'wp-chat',
     115                __( 'Overview', 'smashballoon-wpchat-livechat-customer-support' ),
     116                __( 'Overview', 'smashballoon-wpchat-livechat-customer-support' ),
     117                'wpchat_admin',
     118                'wp-chat',
     119                array($this, 'renderAdminPage'),
     120                );
     121
     122                $settings         = $this->settingsService->getAllSettings();
     123                $onboardingStatus = $settings['onboardingStatus'] ?? false;
     124
     125                if ( $onboardingStatus ) {
     126                        add_submenu_page(
    102127                                'wp-chat',
    103128                                __( 'Visibility', 'smashballoon-wpchat-livechat-customer-support' ),
    … …  
    106131                                'wp-chat#/visibility',
    107132                                array($this, 'renderAdminPage'),
    108 );
    109 
    110 add_submenu_page(
     133                        );
     134
     135                        add_submenu_page(
    111136                                'wp-chat',
    112137                                __( 'Customizer', 'smashballoon-wpchat-livechat-customer-support' ),
    … …  
    115140                                'wp-chat#/customizer',
    116141                                array($this, 'renderAdminPage'),
    117 );
    118 
    119 
    120 add_submenu_page(
     142                        );
     143
     144
     145                        add_submenu_page(
    121146                                'wp-chat',
    122147                                __( 'Agents', 'smashballoon-wpchat-livechat-customer-support' ),
    … …  
    125150                                'wp-chat#/agents',
    126151                                array($this, 'renderAdminPage'),
    127 );
    128 
    129 add_submenu_page(
     152                        );
     153
     154                        add_submenu_page(
    130155                                'wp-chat',
    131156                                __( 'Frequent Questions', 'smashballoon-wpchat-livechat-customer-support' ),
    … …  
    134159                                'wp-chat#/faqs',
    135160                                array($this, 'renderAdminPage'),
    136 );
    137 
    138 add_submenu_page(
     161                        );
     162
     163                        add_submenu_page(
    139164                                'wp-chat',
    140165                                __( 'Chat Funnels', 'smashballoon-wpchat-livechat-customer-support' ),
    … …  
    143168                                'wp-chat#/funnels',
    144169                                array($this, 'renderAdminPage'),
    145 );
    146 
    147 add_submenu_page(
     170                        );
     171
     172                        add_submenu_page(
    148173                                'wp-chat',
    149174                                __( 'Settings', 'smashballoon-wpchat-livechat-customer-support' ),
    … …  
    152177                                'wp-chat#/settings',
    153178                                array($this, 'renderAdminPage'),
    154 );
    155 
    156 add_submenu_page(
     179                        );
     180
     181                        add_submenu_page(
    157182                                'wp-chat',
    158183                                __( 'Support', 'smashballoon-wpchat-livechat-customer-support' ),
    … …  
    161186                                'wp-chat#/support',
    162187                                array($this, 'renderAdminPage'),
    163 );
    164 
    165     if ( ! defined( 'WPCHAT_PRO' ) || ! WPCHAT_PRO ) {
    166                                 add_submenu_page(
    167         'wp-chat',
    168         __( 'Upgrade', 'smashballoon-wpchat-livechat-customer-support' ),
    169         sprintf(
    170             '<div class="sb-wpchat-pro-upgrade-link-container"><div class="sb-wpchat-pro-upgrade-link-bg"></div><strong class="sb-wpchat-pro-upgrade-link">%s</strong></div>',
    171             __( 'Upgrade', 'smashballoon-wpchat-livechat-customer-support' )
    172         ),
    173         'wpchat_admin',
    174         UTMUrlGenerator::upgradeUrl(),
    175         ''
    176                                 );
    177     }
    178     }
    179 
    180 }
     188                        );
     189
     190                        if ( ! defined( 'WPCHAT_PRO' ) || ! WPCHAT_PRO ) {
     191                                                add_submenu_page(
     192                                'wp-chat',
     193                                __( 'Upgrade', 'smashballoon-wpchat-livechat-customer-support' ),
     194                                sprintf(
     195                                        '<div class="sb-wpchat-pro-upgrade-link-container"><div class="sb-wpchat-pro-upgrade-link-bg"></div><strong class="sb-wpchat-pro-upgrade-link">%s</strong></div>',
     196                                        __( 'Upgrade', 'smashballoon-wpchat-livechat-customer-support' )
     197                                                ),
     198                                                'wpchat_admin',
     199                                                UTMUrlGenerator::upgradeUrl(),
     200                                                ''
     201                                                );
     202                        }
     203                }
     204
     205        }
    181206
    182207/**
    … …  
    185210 * @return void
    186211 */
    187 public function renderAdminPage() {
    188     include WPCHAT_PLUGIN_DIR . 'templates/admin-ui.php';
    189 }
    190 
    191 /**
    192  * Handle access token capture from URL parameters
     212        public function renderAdminPage()
     213        {
     214                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Selects which screen to show; no write.
     215                $connectState = isset($_GET['wpchat_connect']) ? sanitize_key(wp_unslash($_GET['wpchat_connect'])) : '';
     216
     217                if (in_array($connectState, array('confirm', 'none'), true) && $this->currentUserIsWpChatAdmin()) {
     218                        $connectId = $this->getPending()['id'];
     219                        if ('confirm' === $connectState && '' === $connectId) {
     220                                $connectState = 'none';
     221                        }
     222                        include WPCHAT_PLUGIN_DIR . 'templates/admin-connect-token.php';
     223                        return;
     224                }
     225
     226                include WPCHAT_PLUGIN_DIR . 'templates/admin-ui.php';
     227        }
     228
     229        /**
     230         * Move the token from the verification link out of the URL, before the page renders.
     231         *
     232         * After email verification, the WPChat API sends the admin to admin.php?page=wp-chat&access_token=….
     233         * That URL cannot carry a nonce, so the token is only parked for the current admin, and only
     234         * when this site claimed Smart Search in the last 24 hours (the life of the emailed link).
     235         * handleAccessToken() saves it after the admin confirms (SMASH-2676).
     236         *
     237         * @return void
     238         */
     239        public function captureAccessTokenFromUrl()
     240        {
     241                $token = $this->getAccessTokenFromUrl();
     242                if ('' === $token || !$this->currentUserIsWpChatAdmin()) {
     243                        return;
     244                }
     245
     246                $state = 'none';
     247                if ($this->smartSearchManager->hasRecentClaimOffer()) {
     248                        // The id goes into the Connect form, so the POST saves only the token its screen was for.
     249                        $pending = array('token' => $token, 'id' => wp_generate_password(20, false, false));
     250                        set_transient(self::PENDING_TOKEN_TRANSIENT . get_current_user_id(), $pending, 30 * MINUTE_IN_SECONDS);
     251                        $state = 'confirm';
     252                }
     253
     254                wp_safe_redirect(add_query_arg('wpchat_connect', $state, admin_url('admin.php?page=wp-chat')));
     255                exit();
     256        }
     257
     258        /**
     259         * Save the parked access token after the admin confirms it.
     260         *
     261         * The admin_init hook also fires on admin-post.php and admin-ajax.php, including for logged-out
     262         * requests, so the capability and nonce checks here are the only guard (SMASH-2676).
     263         *
     264         * @return void
     265         */
     266        public function handleAccessToken()
     267        {
     268                // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Presence check only; the nonce is verified below before any write.
     269                if (!isset($_POST['wpchat_connect_confirm'])) {
     270                        return;
     271                }
     272
     273                if (!$this->currentUserIsWpChatAdmin()) {
     274                        return;
     275                }
     276
     277                check_admin_referer(self::CONNECT_TOKEN_NONCE_ACTION, 'wpchat_connect_nonce');
     278
     279                $accessToken = $this->pendingTokenForForm();
     280                delete_transient(self::PENDING_TOKEN_TRANSIENT . get_current_user_id());
     281
     282                if ('' === $accessToken) {
     283                        wp_safe_redirect(add_query_arg('wpchat_connect', 'none', admin_url('admin.php?page=wp-chat')));
     284                        exit();
     285                }
     286
     287                $this->privateSettingsService->updateSetting('api_token', $accessToken);
     288
     289                // Hooks run before the redirect (vector generation, usage stats).
     290                do_action('wpchat_api_token_updated', $accessToken);
     291
     292                wp_safe_redirect(admin_url('admin.php?page=wp-chat'));
     293                exit();
     294        }
     295
     296        /**
     297         * The token and one-time id parked for the current user by captureAccessTokenFromUrl().
     298         * Both are '' when nothing is parked.
     299         *
     300         * @return array{token: string, id: string}
     301         */
     302        private function getPending(): array
     303        {
     304                $pending = get_transient(self::PENDING_TOKEN_TRANSIENT . get_current_user_id());
     305                if (!is_array($pending) || !isset($pending['token'], $pending['id'])) {
     306                        return array('token' => '', 'id' => '');
     307                }
     308
     309                return array('token' => (string) $pending['token'], 'id' => (string) $pending['id']);
     310        }
     311
     312        /**
     313         * The parked token, but only when the confirmed form carries its one-time id; '' otherwise.
     314         *
     315         * A link loaded after the Connect screen rendered replaces the parked token and its id, so
     316         * the older screen's POST saves nothing (SMASH-2676). Call after the nonce check.
     317         *
     318         * @return string
     319         */
     320        private function pendingTokenForForm(): string
     321        {
     322                $pending = $this->getPending();
     323                // phpcs:disable WordPress.Security.NonceVerification.Missing -- handleAccessToken() verified the nonce.
     324                $postedId = isset($_POST['wpchat_connect_id'])
     325                        ? sanitize_text_field(wp_unslash($_POST['wpchat_connect_id']))
     326                        : '';
     327                // phpcs:enable WordPress.Security.NonceVerification.Missing
     328
     329                // Nothing parked gives '' for both, so the token returned is '' too.
     330                return hash_equals($pending['id'], $postedId) ? $pending['token'] : '';
     331        }
     332
     333        /**
     334         * The access token the WPChat API put in the admin page URL, or '' when there is none.
     335         *
     336         * @return string
     337         */
     338        private function getAccessTokenFromUrl(): string
     339        {
     340                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Parked for confirmation only; saving needs the nonce in handleAccessToken().
     341                if (!isset($_GET['access_token']) || !is_string($_GET['access_token'])) {
     342                        return '';
     343                }
     344
     345                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- As above.
     346                return sanitize_text_field(wp_unslash($_GET['access_token']));
     347        }
     348
     349        /**
     350         * Same check as the admin REST routes (RestEndpoint::checkPermission()).
     351         *
     352         * @return bool
     353         */
     354        private function currentUserIsWpChatAdmin(): bool
     355        {
     356                return current_user_can('manage_options') || current_user_can('wpchat_admin');
     357        }
     358
     359/**
     360 * Remove admin notices on WPChat pages
    193361 *
    194362 * @return void
    195363 */
    196 public function handleAccessToken() {
    197     // Check if we're on a WPChat admin page and have an access_token parameter
     364        public function removeAdminNotices() {
    198365                // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Read-only check of admin page parameter, no data modification
    199     $currentPage  = $_GET['page'] ?? '';
    200     $isWpChatPage = (strpos( $currentPage, 'wp-chat' ) === 0);
    201 
    202                 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callback from external service cannot include nonce
    203     if ( $isWpChatPage && isset( $_GET['access_token'] ) ) {
    204                         // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.NonceVerification.Recommended -- OAuth access token from external service, properly sanitized
    205     $accessToken = sanitize_text_field( wp_unslash( $_GET['access_token'] ) );
    206 
    207     // Validate that the token is not empty
    208     if ( ! empty( $accessToken ) ) {
    209                                 // Store the access token in private settings
    210                                 $this->privateSettingsService->updateSetting( 'api_token', $accessToken );
    211 
    212                                 // Trigger any actions that need to happen after token update
    213                                 // This ensures hooks are processed before redirect
    214                                 do_action( 'wpchat_api_token_updated', $accessToken );
    215 
    216                                 // Redirect to remove the token from the URL for security
    217                                 $redirectUrl = remove_query_arg( 'access_token' );
    218                                 wp_safe_redirect( $redirectUrl );
    219                                 exit();
    220     }
    221     }
    222 }
    223 
    224 /**
    225  * Remove admin notices on WPChat pages
    226  *
    227  * @return void
    228  */
    229 public function removeAdminNotices() {
    230                 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Read-only check of admin page parameter, no data modification
    231     $currentPage  = $_GET['page'] ?? '';
    232     $isWpChatPage = (strpos( $currentPage, 'wp-chat' ) === 0);
    233 
    234     if ( $isWpChatPage ) {
    235     remove_all_actions( 'admin_notices' );
    236     remove_all_actions( 'all_admin_notices' );
    237     }
    238 }
     366                $currentPage  = $_GET['page'] ?? '';
     367                $isWpChatPage = (strpos( $currentPage, 'wp-chat' ) === 0);
     368
     369                if ( $isWpChatPage ) {
     370                        remove_all_actions( 'admin_notices' );
     371                        remove_all_actions( 'all_admin_notices' );
     372                }
     373        }
    239374
    240375/**
    … …  
    247382 * @return array Modified capabilities.
    248383 */
    249 public function filterUserCapabilities( array $allcaps, array $caps, array $args, $user ): array {
    250     if ( isset( $allcaps['manage_options'] ) && $allcaps['manage_options'] ) {
    251     $allcaps['wpchat_admin'] = true;
    252     }
    253 
    254     return $allcaps;
    255 }
     384        public function filterUserCapabilities( array $allcaps, array $caps, array $args, $user ): array {
     385                if ( isset( $allcaps['manage_options'] ) && $allcaps['manage_options'] ) {
     386                        $allcaps['wpchat_admin'] = true;
     387                }
     388
     389                return $allcaps;
     390        }
    256391
    257392/**
    … …  
    261396 * @return array Modified capabilities array.
    262397 */
    263 public function registerMembersCapabilities( array $caps ): array {
    264     $caps[] = 'wpchat_admin';
    265 
    266     return $caps;
     398        public function registerMembersCapabilities( array $caps ): array {
     399                $caps[] = 'wpchat_admin';
     400
     401                return $caps;
     402        }
    267403}
    268 }
  • smashballoon-wpchat-livechat-customer-support/trunk/src/Common/Services/Database/FaqSearchService.php

    r3760121 r3798396  
    88use SmashBalloon\WPChat\Common\Services\VectorEmbeddingService;
    99use SmashBalloon\WPChat\Common\Services\SettingsService;
     10use SmashBalloon\WPChat\Common\Services\SmartSearchRateLimiter;
    1011use Exception;
    1112use wpdb;
    … …  
    5051
    5152        /**
     53         * Per-visitor limit on searches that spend the site's WPChat API tokens.
     54         *
     55         * @var SmartSearchRateLimiter
     56         */
     57        protected SmartSearchRateLimiter $rateLimiter;
     58
     59        /**
    5260         * Constructor.
    5361         *
    54          * @param wpdb $db The WordPress database object.
    55          * @param SettingsService $settingsService The settings service.
    56          * @param VectorEmbeddingService|null $vectorService The vector embedding service (optional).
    57          */
    58         public function __construct(wpdb $db, SettingsService $settingsService, ?VectorEmbeddingService $vectorService = null)
    59         {
     62         * @param wpdb                        $db              The WordPress database object.
     63         * @param SettingsService             $settingsService The settings service.
     64         * @param VectorEmbeddingService|null $vectorService   The vector embedding service (optional).
     65         * @param SmartSearchRateLimiter|null $rateLimiter     The smart search limiter (optional).
     66         */
     67        public function __construct(
     68                wpdb $db,
     69                SettingsService $settingsService,
     70                ?VectorEmbeddingService $vectorService = null,
     71                ?SmartSearchRateLimiter $rateLimiter = null
     72        ) {
    6073                $this->db = $db;
    6174                $this->tableName = $db->prefix . 'wpchat_faqs';
    6275                $this->settingsService = $settingsService;
    6376                $this->vectorService = $vectorService;
     77                $this->rateLimiter = $rateLimiter ?? new SmartSearchRateLimiter();
    6478        }
    6579
    … …  
    95109                $smartSearchEnabled = (bool) apply_filters('wpchat_smart_search_enabled', $smartSearchEnabled, $query);
    96110
    97                 // Try vector search first if available and enabled
    98                 if ($this->vectorService && $smartSearchEnabled) {
     111                // Try vector search first if available and enabled. Over the per-visitor limit, the search
     112                // falls through to the local keyword search so it does not spend the site's tokens.
     113                if ($this->vectorService && $smartSearchEnabled && $this->rateLimiter->attempt($query)) {
    99114                        try {
    100115                                $vectorResults = $this->vectorService->searchByVector($query, $options['limit'] ?? 10);
  • smashballoon-wpchat-livechat-customer-support/trunk/src/Common/Services/SettingsService.php

    r3760121 r3798396  
    6161        public function register(): void {
    6262        }
     63
     64        /** Response-only settings fields that must never be stored or served publicly. */
     65        public const ADMIN_ONLY_KEYS = array( 'hasAccessToken', 'tokenUsage', 'newsletterStatus' );
    6366
    6467        /**
    … …  
    173176                }
    174177
    175                 return $merged_settings;
     178                // Admin-only live values are added by SettingsEndpoint::getSettings().
     179                // Drop any stale stored copy so no caller can leak it to non-admins.
     180                return array_diff_key( $merged_settings, array_flip( self::ADMIN_ONLY_KEYS ) );
    176181        }
    177182
    … …  
    281286         */
    282287        public function updateSettings(array $settings): bool {
    283                 $currentSettings = $this->repository->getSettings();
     288                // Drop any stale stored copy of the admin-only keys so this save cleans it.
     289                $currentSettings = array_diff_key( $this->repository->getSettings(), array_flip( self::ADMIN_ONLY_KEYS ) );
    284290
    285291                // Pro forces telemetry + in-plugin notifications on at read time, so never
  • smashballoon-wpchat-livechat-customer-support/trunk/src/Common/Services/SingletonContainer.php

    r3423106 r3798396  
    6868                }
    6969                self::$instance->set(
     70                        SmartSearchRateLimiter::class,
     71                        autowire(SmartSearchRateLimiter::class)
     72                                ->constructorParameter('privateSettings', \SmashBalloon\WPChat\Vendor\DI\get(PrivateSettingsService::class))
     73                );
     74                self::$instance->set(
    7075                        SearchServiceInterface::class,
    7176                        autowire(FaqSearchService::class)
    7277                                ->constructorParameter('vectorService', \SmashBalloon\WPChat\Vendor\DI\get(VectorEmbeddingService::class))
     78                                ->constructorParameter('rateLimiter', \SmashBalloon\WPChat\Vendor\DI\get(SmartSearchRateLimiter::class))
    7379                );
    7480
  • smashballoon-wpchat-livechat-customer-support/trunk/src/Common/Services/SmartSearchManagerService.php

    r3376414 r3798396  
    131131                }
    132132
    133                 $subscriptionTime = new \DateTime($subscriptionDate);
    134                 $now = new \DateTime();
    135                 $hoursDiff = ($now->getTimestamp() - $subscriptionTime->getTimestamp()) / 3600;
     133                // claimOffer() stores current_time('mysql'), which is in the site's timezone.
     134                try {
     135                        $subscriptionTime = new \DateTime((string) $subscriptionDate, wp_timezone());
     136                } catch (\Exception $e) {
     137                        return false;
     138                }
     139
     140                $hoursDiff = (time() - $subscriptionTime->getTimestamp()) / 3600;
    136141
    137142                return $hoursDiff < 24;
  • smashballoon-wpchat-livechat-customer-support/trunk/src/Common/Services/VectorEmbeddingService.php

    r3567449 r3798396  
    1616class VectorEmbeddingService implements ServiceProviderInterface
    1717{
     18        /**
     19         * Longest visitor query, in characters, sent to the embeddings API (SMASH-2676).
     20         */
     21        public const SEARCH_QUERY_MAX_CHARS = 200;
     22
    1823        private ?SQLite3 $db = null;
    1924        private string $vectorFilePath;
    … …  
    483488        {
    484489                try {
    485                         $queryVector = $this->generateEmbedding($query);
     490                        // The query comes from a public route and is billed by length, so only the start is sent.
     491                        $queryVector = $this->generateEmbedding(mb_substr($query, 0, self::SEARCH_QUERY_MAX_CHARS));
    486492
    487493                        // Get similar vectors with their scores
  • smashballoon-wpchat-livechat-customer-support/trunk/vendor/composer/installed.php

    r3760121 r3798396  
    33namespace SmashBalloon\WPChat\Vendor;
    44
    5 return array('root' => array('name' => 'smashballoon/wpchat', 'pretty_version' => 'v1.4.5', 'version' => '1.4.5.0', 'reference' => '8203a421eef5907ec2f5238f839c6ae61762c22b', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), 'dev' => \false), 'versions' => array('laravel/serializable-closure' => array('pretty_version' => 'v1.3.7', 'version' => '1.3.7.0', 'reference' => '4f48ade902b94323ca3be7646db16209ec76be3d', 'type' => 'library', 'install_path' => __DIR__ . '/../laravel/serializable-closure', 'aliases' => array(), 'dev_requirement' => \false), 'php-di/invoker' => array('pretty_version' => '2.3.7', 'version' => '2.3.7.0', 'reference' => '3c1ddfdef181431fbc4be83378f6d036d59e81e1', 'type' => 'library', 'install_path' => __DIR__ . '/../php-di/invoker', 'aliases' => array(), 'dev_requirement' => \false), 'php-di/php-di' => array('dev_requirement' => \false, 'replaced' => array(0 => '6.4.0')), 'php-di/phpdoc-reader' => array('pretty_version' => '2.2.1', 'version' => '2.2.1.0', 'reference' => '66daff34cbd2627740ffec9469ffbac9f8c8185c', 'type' => 'library', 'install_path' => __DIR__ . '/../php-di/phpdoc-reader', 'aliases' => array(), 'dev_requirement' => \false), 'psr/container' => array('pretty_version' => '1.1.2', 'version' => '1.1.2.0', 'reference' => '513e0666f7216c7459170d56df27dfcefe1689ea', 'type' => 'library', 'install_path' => __DIR__ . '/../psr/container', 'aliases' => array(), 'dev_requirement' => \false), 'smashballoon/framework' => array('pretty_version' => 'dev-master', 'version' => 'dev-master', 'reference' => '3c53a9a74cc5bbdb4df2cfec13007f4793404e0f', 'type' => 'library', 'install_path' => __DIR__ . '/../smashballoon/framework', 'aliases' => array(0 => '1.0.0', 1 => '9999999-dev'), 'dev_requirement' => \false), 'smashballoon/wpchat' => array('pretty_version' => 'v1.4.5', 'version' => '1.4.5.0', 'reference' => '8203a421eef5907ec2f5238f839c6ae61762c22b', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), 'dev_requirement' => \false)));
     5return array('root' => array('name' => 'smashballoon/wpchat', 'pretty_version' => 'v1.4.6', 'version' => '1.4.6.0', 'reference' => 'e3d3b5df8cd2df0c43fb388fae64000e6936afd4', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), 'dev' => \false), 'versions' => array('laravel/serializable-closure' => array('pretty_version' => 'v1.3.7', 'version' => '1.3.7.0', 'reference' => '4f48ade902b94323ca3be7646db16209ec76be3d', 'type' => 'library', 'install_path' => __DIR__ . '/../laravel/serializable-closure', 'aliases' => array(), 'dev_requirement' => \false), 'php-di/invoker' => array('pretty_version' => '2.3.7', 'version' => '2.3.7.0', 'reference' => '3c1ddfdef181431fbc4be83378f6d036d59e81e1', 'type' => 'library', 'install_path' => __DIR__ . '/../php-di/invoker', 'aliases' => array(), 'dev_requirement' => \false), 'php-di/php-di' => array('dev_requirement' => \false, 'replaced' => array(0 => '6.4.0')), 'php-di/phpdoc-reader' => array('pretty_version' => '2.2.1', 'version' => '2.2.1.0', 'reference' => '66daff34cbd2627740ffec9469ffbac9f8c8185c', 'type' => 'library', 'install_path' => __DIR__ . '/../php-di/phpdoc-reader', 'aliases' => array(), 'dev_requirement' => \false), 'psr/container' => array('pretty_version' => '1.1.2', 'version' => '1.1.2.0', 'reference' => '513e0666f7216c7459170d56df27dfcefe1689ea', 'type' => 'library', 'install_path' => __DIR__ . '/../psr/container', 'aliases' => array(), 'dev_requirement' => \false), 'smashballoon/framework' => array('pretty_version' => 'dev-master', 'version' => 'dev-master', 'reference' => '3c53a9a74cc5bbdb4df2cfec13007f4793404e0f', 'type' => 'library', 'install_path' => __DIR__ . '/../smashballoon/framework', 'aliases' => array(0 => '1.0.0', 1 => '9999999-dev'), 'dev_requirement' => \false), 'smashballoon/wpchat' => array('pretty_version' => 'v1.4.6', 'version' => '1.4.6.0', 'reference' => 'e3d3b5df8cd2df0c43fb388fae64000e6936afd4', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), 'dev_requirement' => \false)));
  • smashballoon-wpchat-livechat-customer-support/trunk/wp-chat.php

    r3760121 r3798396  
    55 * Plugin URI: https://wpchat.com
    66 * Description: Add a click-to-chat button to your WordPress site so visitors can message you on WhatsApp, Messenger, Telegram, or Instagram in one click.
    7  * Version: 1.4.5
     7 * Version: 1.4.6
    88 * Author: Smash Balloon
    99 * Author URI: https://smashballoon.com
    … …  
    2323}
    2424if (!defined('WPCHAT_VERSION')) {
    25         define('WPCHAT_VERSION', '1.4.5');
     25        define('WPCHAT_VERSION', '1.4.6');
    2626}
    2727if (!defined('WPCHAT_PLUGIN_FILE')) {
Note: See TracChangeset for help on using the changeset viewer.