Plugin Directory

Changeset 3790318


Ignore:
Timestamp:
11/08/2026 12:50:59 AM (20 hours ago)
Author:
codersly
Message:

Tracking Updated

Location:
codersly-visitor-tracker
Files:
36 added
3 deleted
10 edited

Legend:

Unmodified
Added
Removed
  • codersly-visitor-tracker/trunk/assets/public/js/public.js

    r3670318 r3790318  
    1 /* Codersly Visitor Tracker 1.1.0: reliable first-party page-view beacon. */
     1/* Codersly Visitor Tracker 1.1.3: reliable first-party page-view beacon. */
    22(() => {
    33  'use strict';
    … …  
    7979  };
    8080
    81   const inferReferrerFromUrl = () => {
    82     try {
    83       const params = new URLSearchParams(window.location.search || '');
    84       const source = String(params.get('utm_source') || params.get('source') || params.get('ref') || '').toLowerCase();
    85       if (params.has('fbclid') || /facebook|instagram|meta/.test(source)) return 'https://www.facebook.com/';
    86       if (params.has('gclid') || params.has('gbraid') || params.has('wbraid') || /google/.test(source)) return 'https://www.google.com/';
    87       if (params.has('ttclid') || /tiktok/.test(source)) return 'https://www.tiktok.com/';
    88       if (/linkedin/.test(source)) return 'https://www.linkedin.com/';
    89       if (/youtube/.test(source)) return 'https://www.youtube.com/';
    90       if (/twitter|x\.com/.test(source)) return 'https://x.com/';
    91     } catch (e) {}
    92     return '';
    93   };
    94 
    9581  const attribution = (() => {
    9682    const path = nowPath();
    97     const serverReferrer = String(cfg.serverReferrer || '');
    98     const referrer = String(document.referrer || (!sameHost(serverReferrer) ? serverReferrer : '') || inferReferrerFromUrl() || '');
     83    const referrer = String(document.referrer || '');
    9984    let landing = safeGet(window.sessionStorage, LANDING_KEY);
    10085    let firstReferrer = safeGet(window.sessionStorage, FIRST_REF_KEY);
    … …  
    130115    try { if (navigator.webdriver) flags.push('webdriver'); } catch (e) {}
    131116    try { if (!navigator.languages || !navigator.languages.length) flags.push('no_languages'); } catch (e) {}
    132     try { if (window.callPhantom || window._phantom || window.__nightmare) flags.push('automation_global'); } catch (e) {}
     117    try { if (window.callPhantom || window._phantom || window.__nightmare) flags.push('automation_globals'); } catch (e) {}
    133118    return flags.join(',');
    134119  };
    … …  
    172157      let body = null;
    173158      try { body = await response.json(); } catch (e) {}
    174       return { ok: !!(response && response.ok), method: 'rest', http_status: Number(response.status || 0), body };
     159      return { ok: !!(response && response.ok && body && body.success === true), method: 'rest', http_status: Number(response.status || 0), body };
    175160    } catch (e) { return { ok: false, method: 'rest', error: 'network_error' }; }
    176161  };
    … …  
    192177      let body = null;
    193178      try { body = await response.json(); } catch (e) {}
    194       return { ok: !!(response && response.ok), method: 'ajax', http_status: Number(response.status || 0), body };
     179      return { ok: !!(response && response.ok && body && body.success === true), method: 'ajax', http_status: Number(response.status || 0), body };
    195180    } catch (e) { return { ok: false, method: 'ajax', error: 'network_error' }; }
    196181  };
    … …  
    205190
    206191  let sent = false;
     192  const prerendering = () => document.prerendering === true || document.visibilityState === 'prerender';
    207193  const send = async () => {
    208     if (sent || document.visibilityState === 'prerender') return;
     194    if (sent || prerendering()) return;
    209195    sent = true;
    210196    // Use admin-ajax first for every visitor. It preserves WordPress login cookies
    … …  
    224210
    225211  const sendOnExit = () => {
    226     if (sent) return;
     212    if (sent || prerendering()) return;
    227213    sent = true;
    228214    // admin-ajax supports both logged-in and guest requests and keeps the current
    … …  
    247233
    248234  const delay = Math.max(0, Number(cfg.delayMs || 0));
    249   if (delay > 0) window.setTimeout(send, delay); else Promise.resolve().then(send);
     235  const start = () => {
     236    if (delay > 0) window.setTimeout(send, delay); else Promise.resolve().then(send);
     237  };
     238  if (prerendering()) document.addEventListener('prerenderingchange', start, { once: true });
     239  else start();
    250240  window.addEventListener('pagehide', sendOnExit, { once: true, passive: true });
    251241  document.addEventListener('visibilitychange', () => {
  • codersly-visitor-tracker/trunk/codersly-visitor-tracker.php

    r3704726 r3790318  
    33 * Plugin Name: Codersly Visitor Tracker
    44 * Description: Lightweight visitor tracking with bot/referrer recording, privacy controls, and an admin dashboard.
    5  * Version: 1.1.2
     5 * Version: 1.1.3
    66 * Author:      Codersly
    77 * Author URI:  https://codersly.com/
    … …  
    1919define('VTRT_PATH', plugin_dir_path(__FILE__));
    2020define('VTRT_URL', plugin_dir_url(__FILE__));
    21 define('VTRT_VER', '1.1.2');
     21define('VTRT_VER', '1.1.3');
    2222if (!defined('VTRT_TBL_VISITORS')) { define('VTRT_TBL_VISITORS', 'vtrt_visitors'); }
    2323function vtrt_require(string $relative_path): void { $file = VTRT_PATH . ltrim($relative_path, '/\\'); if (is_file($file)) { require_once $file; } }
  • codersly-visitor-tracker/trunk/includes/Admin/Admin.php

    r3612240 r3790318  
    2727        public static function init() : void {
    2828                add_action( 'admin_menu', [ __CLASS__, 'register_menus' ], 10 );
     29                require_once __DIR__ . '/Bot_Review.php';
     30                Bot_Review::init();
    2931
    3032                $file = __DIR__ . '/Settings_Page.php';
  • codersly-visitor-tracker/trunk/includes/Admin/List_Table_Visitors.php

    r3670318 r3790318  
    122122        public function column_type( array $item ) : string {
    123123                $type  = (string) ( $item['visitor_type'] ?: ( (int) ( $item['is_bot'] ?? 0 ) === 1 ? 'suspected_bot' : 'unknown' ) );
    124                 $class = 'human' === $type ? 'vtrt-human' : ( in_array( $type, [ 'verified_bot', 'suspected_bot' ], true ) ? 'vtrt-bot' : 'muted' );
     124                $class = 'human' === $type ? 'vtrt-human' : ( $this->is_bot_row( $item ) ? 'vtrt-bot' : 'muted' );
    125125                $label = 'unknown' === $type ? __( 'Unverified', 'codersly-visitor-tracker' ) : ucwords( str_replace( '_', ' ', $type ) );
    126                 return '<span class="vtrt-badge ' . esc_attr( $class ) . '">' . esc_html( $label ) . '</span>';
     126                $reason = str_replace( '_', ' ', (string) ( $item['bot_reason'] ?? '' ) );
     127                $badge = '<span class="vtrt-badge ' . esc_attr( $class ) . '" title="' . esc_attr( $reason ) . '">' . esc_html( $label ) . '</span>';
     128                return $this->is_bot_row( $item ) ? $badge . '<br>' . $this->bot_company_cell( $item ) : $badge;
    127129        }
    128130
    … …  
    139141
    140142        public function column_origin( array $item ) : string {
    141                 if ( $this->is_bot_row( $item ) ) {
    142                         $bot_referrer = $this->bot_company_referrer_cell( $item );
    143                         if ( '' !== $bot_referrer ) {
    144                                 return $bot_referrer;
    145                         }
    146                 }
    147 
    148                 $origin = (string) ( $item['referrer_raw'] ?: ( $item['origin'] ?? '' ) );
    149                 $first  = (string) ( $item['session_first_referrer'] ?? '' );
     143                $origin = (string) ( ( $item['referrer_raw'] ?? '' ) ?: ( $item['origin'] ?? '' ) );
    150144                $host   = (string) ( $item['referrer_host'] ?? '' );
    151145                $type   = (string) ( $item['referrer_type'] ?? '' );
    … …  
    173167        }
    174168
    175         private function bot_company_referrer_cell( array $item ) : string {
     169        private function bot_company_cell( array $item ) : string {
    176170                $bot_name = (string) ( $item['bot_name'] ?? '' );
    177171                $agent    = (string) ( $item['agent'] ?? '' );
    … …  
    180174                if ( '' !== $domain ) {
    181175                        return '<a href="' . esc_url( 'https://' . $domain ) . '" target="_blank" rel="noopener noreferrer">' . esc_html( $domain ) . '</a><br><span class="description">' . esc_html( $company ) . '</span>';
     176                }
     177                if ( false !== strpos( $bot_name, 'Hosting network' ) ) {
     178                        return esc_html__( 'Possible browser automation / Hosting network', 'codersly-visitor-tracker' );
    182179                }
    183180                return esc_html( $company );
  • codersly-visitor-tracker/trunk/includes/Admin/Settings_Page.php

    r3670318 r3790318  
    7979                                        foreach ( (array) $lines as $line ) {
    8080                                                $line = trim( sanitize_text_field( (string) $line ) );
    81                                                 if ( '' !== $line && ( filter_var( $line, FILTER_VALIDATE_IP ) || preg_match( '/^[0-9a-f:.]+\/\d{1,3}$/i', $line ) ) ) {
     81                                                if ( filter_var( $line, FILTER_VALIDATE_IP ) ) {
    8282                                                        $out[] = $line;
     83                                                } elseif ( preg_match( '#^([^/]+)/([1-9]\d{0,2})$#D', $line, $parts ) && filter_var( $parts[1], FILTER_VALIDATE_IP ) ) {
     84                                                        $max = false === strpos( $parts[1], ':' ) ? 32 : 128;
     85                                                        if ( (int) $parts[2] <= $max ) {
     86                                                                $out[] = $line;
     87                                                        }
    8388                                                }
    8489                                        }
    … …  
    137142                                echo '<input type="hidden" name="vtrt_enable_geoip" value="0">';
    138143                                echo '<label><input type="checkbox" name="vtrt_enable_geoip" value="1" ' . checked( $val, true, false ) . ' /> ' . esc_html__( 'Look up visitor city/country using an external GeoIP service', 'codersly-visitor-tracker' ) . '</label>';
    139                                 echo '<p class="description">' . esc_html__( 'When enabled, IP-based city and country are added in the background and cached. You can turn this off at any time.', 'codersly-visitor-tracker' ) . '</p>';
     144                                echo '<p class="description">' . esc_html__( 'City and country are estimated from the public IP in the background. The city may reflect a mobile carrier or VPN exit.', 'codersly-visitor-tracker' ) . '</p>';
    140145                        },
    141146                        'vtrt_settings',
    … …  
    143148                );
    144149
     150
     151                do_action( 'vtrt/settings/geoip_fields' );
    145152
    146153                add_settings_field(
    … …  
    150157                                $values = (array) get_option( 'vtrt_trusted_proxy_ips', [] );
    151158                                echo '<textarea name="vtrt_trusted_proxy_ips" rows="5" cols="55" class="large-text code" placeholder="203.0.113.10&#10;2001:db8::/32">' . esc_textarea( implode( "\n", $values ) ) . '</textarea>';
    152                                 echo '<p class="description">' . esc_html__( 'Add only the IP addresses or CIDR ranges of Cloudflare, Hostinger, a load balancer, or another reverse proxy that directly connects to WordPress. This allows the tracker to safely read the real visitor IP from forwarded headers.', 'codersly-visitor-tracker' ) . '</p>';
     159                                echo '<p class="description">' . esc_html__( 'Cloudflare IP ranges are recognized automatically. Add only other reverse proxies you control and have configured to append X-Forwarded-For or overwrite X-Real-IP.', 'codersly-visitor-tracker' ) . '</p>';
    153160                        },
    154161                        'vtrt_settings',
  • codersly-visitor-tracker/trunk/includes/GeoIP.php

    r3670318 r3790318  
    1414}
    1515
     16// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    1617final class GeoIP {
    17         private const TRANSIENT_PREFIX = 'vtrt_geo_110_';
    18         private const TTL              = 7 * DAY_IN_SECONDS;
     18        /** Preferred provider callbacks receive the IP. Set use_provider_quota to skip the shared lookup budget. */
     19        public const PREFERRED_PROVIDER_HOOK = 'vtrt/geo/preferred_provider';
     20
     21        private const TRANSIENT_PREFIX = 'vtrt_geo_city_113_';
     22        private const DEFAULT_SOURCE = 'default';
     23        private const DAILY_LOOKUP_LIMIT = 200;
     24
     25        /** Check the shared budget, or whether it applies to this IP's preferred lookup. */
     26        public static function quota_exhausted( string $ip = '' ) : bool {
     27                $key = 'vtrt_geo_daily_113_' . gmdate( 'Ymd' );
     28                if ( (int) get_transient( $key ) < self::daily_limit() ) {
     29                        return false;
     30                }
     31                return '' === $ip || true !== ( self::preferred_provider( $ip )['use_provider_quota'] ?? false );
     32        }
     33
     34        private static function daily_limit() : int {
     35                return max( 1, (int) apply_filters( 'vtrt/geo/daily_limit', self::DAILY_LOOKUP_LIMIT ) );
     36        }
     37
     38        private static function preferred_provider( string $ip ) : ?array {
     39                $provider = apply_filters( 'vtrt/geo/preferred_provider', null, $ip );
     40                if ( is_array( $provider ) && is_callable( $provider['lookup'] ?? null )
     41                        && is_string( $provider['cache_key'] ?? null ) && '' !== $provider['cache_key']
     42                        && is_string( $provider['source'] ?? null ) && preg_match( '/^[a-z0-9_.-]{1,40}$/D', $provider['source'] ) ) {
     43                        return $provider;
     44                }
     45                return null;
     46        }
    1947
    2048        public static function resolve( string $ip ) : ?array {
    2149                $ip = trim( $ip );
    22                 if ( '' === $ip || ! self::is_public_ip( $ip ) ) {
    23                         return null;
    24                 }
    25 
    26                 $key    = self::TRANSIENT_PREFIX . md5( $ip );
    27                 $cached = get_transient( $key );
     50                if ( '' === $ip || ! Tracker::is_geolocatable_ip( $ip ) || ! Install::geo_cache_ready() ) {
     51                        return null;
     52                }
     53
     54                $provider = self::preferred_provider( $ip );
     55                $use_provider_quota = true === ( $provider['use_provider_quota'] ?? false );
     56                $source = $provider['source'] ?? self::DEFAULT_SOURCE;
     57                $key = $provider['cache_key'] ?? self::TRANSIENT_PREFIX . md5( $ip );
     58
     59                $cached = self::cached_location( $ip, $source, $key );
    2860                if ( is_array( $cached ) ) {
    29                         return $cached;
    30                 }
    31 
    32                 $providers = (array) apply_filters( 'vtrt/geo/providers', [ 'ipwho', 'ipapico', 'dbip' ] );
    33                 $result    = null;
     61                        return empty( $cached['_failed'] ) ? $cached : null;
     62                }
     63                if ( ! $use_provider_quota && self::quota_exhausted() ) {
     64                        return null;
     65                }
     66
     67                // Serialize external lookups and quota updates when cron workers overlap.
     68                global $wpdb;
     69                $lock = Install::geo_lock_name();
     70                if ( 1 !== (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 2)', $lock ) ) ) {
     71                        return null;
     72                }
     73
     74                try {
     75                        $cached = self::cached_location( $ip, $source, $key );
     76                        if ( is_array( $cached ) ) {
     77                                return empty( $cached['_failed'] ) ? $cached : null;
     78                        }
     79                        if ( ! $use_provider_quota && ! self::consume_shared_quota() ) {
     80                                return null;
     81                        }
     82
     83                        return self::lookup_location( $ip, $source, $key, $provider );
     84                } finally {
     85                        $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) );
     86                }
     87        }
     88
     89        /** Reserve one shared lookup while holding the GeoIP database lock. */
     90        private static function consume_shared_quota() : bool {
     91                global $wpdb;
     92                $quota_key = 'vtrt_geo_daily_113_' . gmdate( 'Ymd' );
     93                $used = (int) self::read_transient( $quota_key, true );
     94                if ( $used >= self::daily_limit() ) {
     95                        return false;
     96                }
     97                if ( 0 === $used ) {
     98                        $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE expires_at > 0 AND expires_at <= %d', Install::geo_cache_table(), time() ) );
     99                }
     100                set_transient( $quota_key, $used + 1, DAY_IN_SECONDS + HOUR_IN_SECONDS );
     101                return true;
     102        }
     103
     104        private static function read_transient( string $key, bool $fresh ) {
     105                if ( $fresh ) {
     106                        if ( wp_using_ext_object_cache() ) {
     107                                return wp_cache_get( $key, 'transient', true );
     108                        }
     109                        // Forget this request's pre-lock option values and negative lookups.
     110                        wp_cache_delete( 'notoptions', 'options' );
     111                        wp_cache_delete( '_transient_' . $key, 'options' );
     112                        wp_cache_delete( '_transient_timeout_' . $key, 'options' );
     113                }
     114                return get_transient( $key );
     115        }
     116
     117        /** Count unique IPs held in the separate location store. */
     118        public static function count_saved_ips() : int {
     119                if ( ! Install::geo_cache_ready() ) {
     120                        return 0;
     121                }
     122                global $wpdb;
     123                return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT ip) FROM %i WHERE country_code <> '' AND (expires_at = 0 OR expires_at > %d)", Install::geo_cache_table(), time() ) );
     124        }
     125
     126        /** Clear saved results without deleting visit history or changing lookup settings. */
     127        public static function clear_saved_locations() : bool {
     128                if ( ! Install::geo_cache_ready() ) {
     129                        return false;
     130                }
     131
     132                global $wpdb;
     133                $lock = Install::geo_lock_name();
     134                if ( 1 !== (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 10)', $lock ) ) ) {
     135                        return false;
     136                }
     137
     138                try {
     139                        return false !== $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', Install::geo_cache_table() ) );
     140                } finally {
     141                        $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) );
     142                }
     143        }
     144
     145        private static function cached_location( string $ip, string $source, string $key ) : ?array {
     146                global $wpdb;
     147                $saved = $wpdb->get_row( $wpdb->prepare(
     148                        'SELECT country, country_code, region, city, timezone, geo_source, provider FROM %i WHERE ip = %s AND (cache_scope IN (%s, %s) OR (cache_scope = %s AND expires_at > %d)) ORDER BY (cache_scope = %s) DESC, expires_at ASC LIMIT 1',
     149                        Install::geo_cache_table(), $ip, 'manual', $source, 'tmp:' . hash( 'sha256', $key ), time(), 'manual'
     150                ), ARRAY_A );
     151                if ( ! is_array( $saved ) ) {
     152                        return null;
     153                }
     154                return '' === $saved['country_code'] ? [ '_failed' => 1 ] : $saved;
     155        }
     156
     157        private static function save_location( string $ip, string $scope, array $result, int $expires_at ) : void {
     158                global $wpdb;
     159                $wpdb->replace(
     160                        Install::geo_cache_table(),
     161                        [
     162                                'ip'           => $ip,
     163                                'cache_scope'  => $scope,
     164                                'country'      => $result['country'] ?? '',
     165                                'country_code' => $result['country_code'] ?? '',
     166                                'region'       => $result['region'] ?? '',
     167                                'city'         => $result['city'] ?? '',
     168                                'timezone'     => $result['timezone'] ?? '',
     169                                'geo_source'   => $result['geo_source'] ?? '',
     170                                'provider'     => $result['provider'] ?? '',
     171                                'expires_at'   => $expires_at,
     172                        ],
     173                        [ '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d' ]
     174                );
     175        }
     176
     177        private static function lookup_location( string $ip, string $source, string $key, ?array $provider ) : ?array {
     178                $lookup = $provider['lookup'] ?? null;
     179                $location = null !== $lookup ? $lookup( $ip ) : null;
     180                $preferred = is_array( $location ) ? self::normalize( $location ) : null;
     181                $result = $preferred;
     182                $result_source = $source;
     183                if ( empty( $preferred['city'] ) ) {
     184                        $fallback = null !== $lookup ? self::cached_location( $ip, self::DEFAULT_SOURCE, self::TRANSIENT_PREFIX . md5( $ip ) ) : null;
     185                        if ( null === $fallback && ( true !== ( $provider['use_provider_quota'] ?? false ) || self::consume_shared_quota() ) ) {
     186                                $fallback = self::resolve_default_providers( $ip );
     187                        }
     188                        if ( is_array( $fallback ) && empty( $fallback['_failed'] )
     189                                && ( null === $preferred || $fallback['country_code'] === $preferred['country_code'] ) ) {
     190                                $result = $fallback;
     191                                $result_source = self::DEFAULT_SOURCE;
     192                        }
     193                }
     194
     195                $temporary = 'tmp:' . hash( 'sha256', $key );
     196                if ( ! is_array( $result ) ) {
     197                        self::save_location( $ip, $temporary, [], time() + HOUR_IN_SECONDS );
     198                        return null;
     199                }
     200
     201                $result['geo_source'] = $result_source;
     202                if ( '' !== $result['city'] ) {
     203                        self::save_location( $ip, $result_source, $result, 0 );
     204                }
     205                if ( '' === $result['city'] || $result_source !== $source ) {
     206                        $ttl = null !== $lookup ? HOUR_IN_SECONDS : DAY_IN_SECONDS;
     207                        self::save_location( $ip, $temporary, $result, time() + $ttl );
     208                }
     209                return $result;
     210        }
     211
     212        private static function resolve_default_providers( string $ip ) : ?array {
     213                $dbip_key  = (string) apply_filters( 'vtrt/geo/dbip_key', '' );
     214                $ipapi_key = (string) apply_filters( 'vtrt/geo/ipapico_key', '' );
     215                $defaults  = [ 'ipwho' ];
     216                if ( '' !== $dbip_key ) {
     217                        $defaults[] = 'dbip';
     218                }
     219                if ( '' !== $ipapi_key ) {
     220                        $defaults[] = 'ipapico';
     221                }
     222                $providers = (array) apply_filters( 'vtrt/geo/providers', $defaults );
     223                $providers = array_unique( array_map( 'sanitize_key', array_filter( $providers, 'is_string' ) ) );
     224                $answers = [];
    34225                foreach ( $providers as $provider ) {
    35                         $provider = sanitize_key( (string) $provider );
     226                        $result = null;
    36227                        if ( 'ipwho' === $provider ) {
    37228                                $result = self::from_ipwho( $ip );
    38229                        } elseif ( 'ipapico' === $provider ) {
    39                                 $result = self::from_ipapico( $ip );
    40                         } elseif ( 'dbip' === $provider ) {
    41                                 $result = self::from_dbip( $ip );
     230                                $result = self::from_ipapico( $ip, $ipapi_key );
     231                        } elseif ( 'dbip' === $provider && '' !== $dbip_key ) {
     232                                $result = self::from_dbip( $ip, $dbip_key );
    42233                        }
    43234                        if ( is_array( $result ) ) {
    44                                 break;
    45                         }
    46                 }
    47 
    48                 if ( is_array( $result ) ) {
    49                         set_transient( $key, $result, (int) apply_filters( 'vtrt/geo/ttl', self::TTL, $ip ) );
    50                         return $result;
    51                 }
    52 
    53                 // Cache a failed lookup briefly so one unreachable provider cannot slow every request.
    54                 set_transient( $key, [ '_failed' => 1 ], HOUR_IN_SECONDS );
    55                 return null;
     235                                $answers[] = $result;
     236                        }
     237                }
     238
     239                return self::reconcile( $answers );
     240        }
     241
     242        /** Keep a usable estimate from one source, and suppress unresolved conflicts. */
     243        private static function reconcile( array $answers ) : ?array {
     244                if ( [] === $answers ) {
     245                        return null;
     246                }
     247
     248                $countries = array_count_values( array_column( $answers, 'country_code' ) );
     249                arsort( $countries );
     250                $country_code = (string) key( $countries );
     251                if ( count( $countries ) > 1 && reset( $countries ) <= count( $answers ) / 2 ) {
     252                        return null;
     253                }
     254
     255                $matching = array_values( array_filter( $answers, static fn( array $answer ) : bool => $country_code === $answer['country_code'] ) );
     256                $result = $matching[0];
     257                foreach ( [ 'city', 'region' ] as $field ) {
     258                        $values = array_values( array_filter( array_column( $matching, $field ) ) );
     259                        $result[ $field ] = '';
     260                        foreach ( $values as $candidate ) {
     261                                $votes = 0;
     262                                foreach ( $values as $value ) {
     263                                        if ( $candidate === $value || self::same_location( $candidate, $value, 'region' === $field ) ) {
     264                                                ++$votes;
     265                                        }
     266                                }
     267                                if ( $votes > count( $values ) / 2 ) {
     268                                        $result[ $field ] = $candidate;
     269                                        break;
     270                                }
     271                        }
     272                }
     273                $result['provider'] = implode( ' + ', array_column( $matching, 'provider' ) );
     274                return $result;
     275        }
     276
     277        private static function same_location( string $a, string $b, bool $region ) : bool {
     278                $normalize = static function ( string $value ) use ( $region ) : string {
     279                        $value = remove_accents( trim( $value ) );
     280                        $value = function_exists( 'mb_strtolower' ) ? mb_strtolower( $value, 'UTF-8' ) : strtolower( $value );
     281                        $value = strtr( $value, [ 'dacca' => 'dhaka', 'chittagong' => 'chattogram', 'comilla' => 'cumilla', 'jessore' => 'jashore', 'barisal' => 'barishal', 'bogra' => 'bogura' ] );
     282                        if ( $region ) {
     283                                $value = (string) preg_replace( '/\b(division|district|region|province|state)\b/i', '', $value );
     284                        }
     285                        return trim( (string) preg_replace( '/[^\p{L}\p{M}\p{N}]+/u', '', $value ) );
     286                };
     287                $a = $normalize( $a );
     288                $b = $normalize( $b );
     289                return '' !== $a && $a === $b;
    56290        }
    57291
    58292        private static function from_ipwho( string $ip ) : ?array {
    59                 $url = 'https://ipwho.is/' . rawurlencode( $ip ) . '?fields=success,country,country_code,region,city,timezone';
    60                 $body = self::request_json( $url );
    61                 if ( ! is_array( $body ) || empty( $body['success'] ) ) {
     293                $url = 'https://ipwho.is/' . rawurlencode( $ip ) . '?fields=ip,success,country,country_code,region,city,timezone';
     294                $body = self::request_json( $url, 'ipwho' );
     295                if ( ! is_array( $body ) || true !== ( $body['success'] ?? false ) || ! self::same_ip( $ip, $body['ip'] ?? null ) ) {
    62296                        return null;
    63297                }
    … …  
    82316        }
    83317
    84         private static function from_ipapico( string $ip ) : ?array {
    85                 $body = self::request_json( 'https://ipapi.co/' . rawurlencode( $ip ) . '/json/' );
    86                 if ( ! is_array( $body ) || ! empty( $body['error'] ) ) {
     318        private static function from_ipapico( string $ip, string $key ) : ?array {
     319                $url = 'https://ipapi.co/' . rawurlencode( $ip ) . '/json/';
     320                $body = self::request_json( $url . ( '' !== $key ? '?key=' . rawurlencode( $key ) : '' ), 'ipapico' );
     321                if ( ! is_array( $body ) || ! empty( $body['error'] ) || ! self::same_ip( $ip, $body['ip'] ?? null ) ) {
    87322                        return null;
    88323                }
    … …  
    100335        }
    101336
    102         private static function from_dbip( string $ip ) : ?array {
    103                 $body = self::request_json( 'https://api.db-ip.com/v2/free/' . rawurlencode( $ip ) );
    104                 if ( ! is_array( $body ) ) {
     337        private static function from_dbip( string $ip, string $key ) : ?array {
     338                $body = self::request_json( 'https://api.db-ip.com/v2/' . rawurlencode( $key ) . '/' . rawurlencode( $ip ), 'dbip' );
     339                if ( ! is_array( $body ) || ! empty( $body['error'] ) || ! self::same_ip( $ip, $body['ipAddress'] ?? null ) ) {
    105340                        return null;
    106341                }
    … …  
    118353        }
    119354
    120         private static function request_json( string $url ) : ?array {
     355        public static function request_json( string $url, string $provider ) : ?array {
     356                $cooldown = 'vtrt_geo_cooldown_' . $provider;
     357                if ( get_transient( $cooldown ) ) {
     358                        return null;
     359                }
    121360                $response = wp_remote_get(
    122361                        $url,
    123362                        [
    124363                                'timeout'     => 2.5,
    125                                 'redirection' => 1,
     364                                'redirection' => 0,
     365                                'limit_response_size' => 32768,
    126366                                'headers'     => [ 'Accept' => 'application/json' ],
    127                                 'user-agent'  => 'Codersly-Visitor-Tracker/1.1.0',
     367                                'user-agent'  => 'Codersly-Visitor-Tracker/1.1.3',
    128368                        ]
    129369                );
    … …  
    132372                }
    133373                $code = (int) wp_remote_retrieve_response_code( $response );
     374                if ( in_array( $code, [ 401, 403, 429 ], true ) ) {
     375                        $retry = (int) wp_remote_retrieve_header( $response, 'retry-after' );
     376                        set_transient( $cooldown, 1, min( DAY_IN_SECONDS, max( HOUR_IN_SECONDS, $retry ) ) );
     377                }
    134378                if ( $code < 200 || $code >= 300 ) {
    135379                        return null;
    … …  
    142386                $out = [
    143387                        'country'      => self::string_value( $data['country'] ?? '', 100 ),
    144                         'country_code' => strtoupper( self::string_value( $data['country_code'] ?? '', 2 ) ),
     388                        'country_code' => strtoupper( self::string_value( $data['country_code'] ?? '' ) ),
    145389                        'region'       => self::string_value( $data['region'] ?? '', 120 ),
    146390                        'city'         => self::string_value( $data['city'] ?? '', 120 ),
    … …  
    148392                        'provider'     => self::string_value( $data['provider'] ?? '', 40 ),
    149393                ];
    150                 return ( '' !== $out['country'] || '' !== $out['city'] ) ? $out : null;
    151         }
    152 
    153         private static function is_public_ip( string $ip ) : bool {
    154                 return (bool) filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE );
     394                return '' !== $out['country'] && preg_match( '/^[A-Z]{2}$/D', $out['country_code'] ) && 'ZZ' !== $out['country_code'] ? $out : null;
     395        }
     396
     397        public static function same_ip( string $ip, $returned ) : bool {
     398                return is_string( $returned ) && filter_var( $returned, FILTER_VALIDATE_IP ) && inet_pton( $ip ) === inet_pton( $returned );
    155399        }
    156400
    … …  
    160404                }
    161405                $value = trim( sanitize_text_field( (string) $value ) );
     406                if ( in_array( strtolower( $value ), [ '-', 'n/a', 'unknown', 'null', 'none', 'not available' ], true ) ) {
     407                        return '';
     408                }
    162409                return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, $max ) : substr( $value, 0, $max );
    163410        }
  • codersly-visitor-tracker/trunk/includes/Install.php

    r3703338 r3790318  
    1717final class Install {
    1818        /** Internal schema marker. */
    19         private const DB_VERSION = '1.1.1-schema3';
     19        private const DB_VERSION = '1.1.3-schema6';
    2020        private const OPT_DB_VERSION = 'vtrt_db_version';
    2121
    … …  
    2626        }
    2727
    28         public static function deactivate() : void {}
     28        public static function deactivate() : void {
     29                wp_clear_scheduled_hook( 'vtrt_async_geo_lookup' );
     30        }
    2931
    3032        public static function maybe_upgrade() : void {
    … …  
    3234                        self::activate();
    3335                }
     36        }
     37
     38        public static function geo_cache_table() : string {
     39                global $wpdb;
     40                return $wpdb->prefix . 'vtrt_geo_cache';
     41        }
     42
     43        public static function geo_lock_name() : string {
     44                global $wpdb;
     45                return 'vtrt_geo_' . md5( DB_NAME . '|' . $wpdb->prefix );
     46        }
     47
     48        public static function geo_cache_ready() : bool {
     49                return (bool) get_option( 'vtrt_geo_cache_initialized', false );
    3450        }
    3551
    … …  
    5167                        region VARCHAR(120) NULL,
    5268                        city VARCHAR(120) NULL,
     69                        geo_retry_at DATETIME NULL,
     70                        geo_source VARCHAR(40) NULL,
    5371                        timezone VARCHAR(80) NULL,
    5472                        path VARCHAR(2048) NOT NULL DEFAULT '',
    … …  
    7795                        KEY ts (ts),
    7896                        KEY ip (ip),
     97                        KEY ip_geo_source (ip,geo_source),
    7998                        KEY user_id (user_id),
    8099                        KEY is_bot (is_bot),
    … …  
    87106                ) {$charset};";
    88107                dbDelta( $sql );
     108                $geo = self::geo_cache_table();
     109                dbDelta( "CREATE TABLE {$geo} (
     110                        ip VARCHAR(64) NOT NULL,
     111                        cache_scope VARCHAR(68) NOT NULL,
     112                        country VARCHAR(100) NOT NULL DEFAULT '',
     113                        country_code VARCHAR(2) NOT NULL DEFAULT '',
     114                        region VARCHAR(120) NULL,
     115                        city VARCHAR(120) NULL,
     116                        timezone VARCHAR(80) NULL,
     117                        geo_source VARCHAR(40) NULL,
     118                        provider VARCHAR(128) NULL,
     119                        expires_at BIGINT UNSIGNED NOT NULL DEFAULT 0,
     120                        PRIMARY KEY  (ip,cache_scope),
     121                        KEY expires_at (expires_at)
     122                ) {$charset};" );
    89123        }
    90124
    … …  
    119153        private static function run_migrations() : void {
    120154                self::widen_url_columns();
    121                 // Enable approximate location once for this requested build. The setting can
    122                 // still be turned off manually from Visitors Tracker → Settings.
    123                 if ( null === get_option( 'vtrt_geoip_auto_enabled_110', null ) ) {
    124                         update_option( 'vtrt_enable_geoip', true, false );
    125                         add_option( 'vtrt_geoip_auto_enabled_110', 1, '', false );
    126                 }
    127                 update_option( self::OPT_DB_VERSION, self::DB_VERSION, false );
     155                global $wpdb;
     156                $table = $wpdb->prefix . ( defined( 'VTRT_TBL_VISITORS' ) ? \VTRT_TBL_VISITORS : 'vtrt_visitors' );
     157                if ( 'geo_retry_at' === $wpdb->get_var( $wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', $table, $wpdb->esc_like( 'geo_retry_at' ) ) )
     158                        && 'geo_source' === $wpdb->get_var( $wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', $table, $wpdb->esc_like( 'geo_source' ) ) )
     159                        && self::initialize_geo_cache( $table ) ) {
     160                        delete_option( 'vtrt_geo_cursor_113' );
     161                        update_option( self::OPT_DB_VERSION, self::DB_VERSION, false );
     162                }
     163        }
     164
     165        private static function initialize_geo_cache( string $visitors ) : bool {
     166                global $wpdb;
     167                $table = self::geo_cache_table();
     168                if ( $table !== $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) ) {
     169                        return false;
     170                }
     171                if ( self::geo_cache_ready() ) {
     172                        return true;
     173                }
     174
     175                $lock = self::geo_lock_name();
     176                if ( 1 !== (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 10)', $lock ) ) ) {
     177                        return false;
     178                }
     179
     180                try {
     181                        // Another request may have completed the migration while we waited.
     182                        wp_cache_delete( 'notoptions', 'options' );
     183                        wp_cache_delete( 'vtrt_geo_cache_initialized', 'options' );
     184                        if ( self::geo_cache_ready() ) {
     185                                return true;
     186                        }
     187                        $copied = $wpdb->query( $wpdb->prepare(
     188                                "INSERT IGNORE INTO %i (ip, cache_scope, country, country_code, region, city, timezone, geo_source, provider)
     189                                SELECT v.ip, v.geo_source, v.country, v.country_code, v.region, v.city, v.timezone, v.geo_source, v.geo_source
     190                                FROM %i v
     191                                INNER JOIN (
     192                                        SELECT MAX(id) AS id FROM %i
     193                                        WHERE ip <> '' AND geo_source <> '' AND city <> '' AND country <> '' AND country_code <> ''
     194                                        GROUP BY ip, geo_source
     195                                ) AS latest ON v.id = latest.id",
     196                                $table, $visitors, $visitors
     197                        ) );
     198                        if ( false === $copied ) {
     199                                return false;
     200                        }
     201
     202                        // These legacy result transients are superseded by the separate table.
     203                        $cleaned = $wpdb->query( $wpdb->prepare(
     204                                'DELETE FROM %i WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s',
     205                                $wpdb->options,
     206                                $wpdb->esc_like( '_transient_vtrt_geo_city_113_' ) . '%',
     207                                $wpdb->esc_like( '_transient_timeout_vtrt_geo_city_113_' ) . '%',
     208                                $wpdb->esc_like( '_transient_vtrt_geo_ipgeo_' ) . '%',
     209                                $wpdb->esc_like( '_transient_timeout_vtrt_geo_ipgeo_' ) . '%'
     210                        ) );
     211                        if ( false === $cleaned ) {
     212                                return false;
     213                        }
     214                        return update_option( 'vtrt_geo_cache_initialized', true, false );
     215                } finally {
     216                        $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) );
     217                }
    128218        }
    129219
  • codersly-visitor-tracker/trunk/includes/Tracker.php

    r3704726 r3790318  
    1818final class Tracker {
    1919        private const GEO_HOOK = 'vtrt_async_geo_lookup';
     20        private const GEO_BATCH_LIMIT = 4;
     21        private const GEO_BACKFILL_DAYS = 30;
    2022
    2123        private static ?array $table_columns = null;
    … …  
    3335
    3436        /**
    35          * Enqueue the browser beacon. Verified human visits are recorded by this JS call.
     37         * Enqueue the browser beacon used to classify and record browser visits.
    3638         */
    3739        public static function enqueue_beacon() : void {
    … …  
    4143
    4244                $script_path    = defined( 'VTRT_PATH' ) ? VTRT_PATH . 'assets/public/js/public.js' : '';
    43                 $script_version = defined( 'VTRT_VER' ) ? (string) VTRT_VER : '1.1.2';
     45                $script_version = defined( 'VTRT_VER' ) ? (string) VTRT_VER : '1.1.3';
    4446                if ( '' !== $script_path && is_file( $script_path ) ) {
    4547                        $modified = filemtime( $script_path );
    … …  
    6062                                'token'    => self::public_beacon_token(),
    6163                                'delayMs'        => (int) apply_filters( 'vtrt/beacon/delay_ms', 0 ),
    62                                 'serverReferrer' => self::referrer(),
    6364                        ]
    6465                );
    … …  
    9495                        $json = $request->get_json_params();
    9596                        if ( is_array( $json ) && isset( $json['nonce'] ) ) {
    96                                 $nonce = sanitize_text_field( (string) $json['nonce'] );
     97                                $nonce = self::beacon_field( $json, 'nonce' );
    9798                        }
    9899                }
    … …  
    101102                        $body = $request->get_body_params();
    102103                        if ( is_array( $body ) && isset( $body['nonce'] ) ) {
    103                                 $nonce = sanitize_text_field( (string) $body['nonce'] );
     104                                $nonce = self::beacon_field( $body, 'nonce' );
    104105                        }
    105106                }
    … …  
    136137
    137138                $user_agent = self::server_text( 'HTTP_USER_AGENT' );
    138                 if ( ! self::is_bot_like_user_agent( $user_agent ) ) {
    139                         return;
    140                 }
    141 
    142139                $ip = self::client_ip();
    143140                if ( '' === $ip || self::is_excluded_ip( $ip ) || self::is_excluded_user() ) {
    … …  
    154151                }
    155152
    156                 if ( self::is_duplicate_recent( $ip, self::dedupe_path( $path ), self::ua_sig( $user_agent ), (string) $detection['visitor_type'], 120 ) ) {
     153                if ( self::is_duplicate_recent( self::storage_ip( $ip ), $path, self::ua_sig( $user_agent ), (string) $detection['visitor_type'], 120 ) ) {
    157154                        return;
    158155                }
    … …  
    174171         */
    175172        public static function ajax_beacon() : void {
    176                 $nonce_ok = false !== check_ajax_referer( 'vtrt_beacon', 'nonce', false );
    177                 $token    = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['token'] ) ) : '';
    178                 if ( ! $nonce_ok && ! self::verify_public_beacon_token( $token ) ) {
     173                $nonce = isset( $_POST['nonce'] ) && is_string( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
     174                $token = isset( $_POST['token'] ) && is_string( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';
     175                if ( ! wp_verify_nonce( $nonce, 'vtrt_beacon' ) && ! self::verify_public_beacon_token( $token ) ) {
    179176                        wp_send_json_error( [ 'message' => 'invalid_token' ], 403 );
    180177                }
    181178
    182                 // phpcs:disable WordPress.Security.NonceVerification.Missing -- Request is verified above.
    183                 $payload = [
    184                         'nonce'                  => isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['nonce'] ) ) : '',
    185                         'token'                  => $token,
    186                         'path'                   => isset( $_POST['path'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['path'] ) ) : '',
    187                         'referrer'               => isset( $_POST['referrer'] ) ? esc_url_raw( wp_unslash( (string) $_POST['referrer'] ) ) : '',
    188                         'client_browser'         => isset( $_POST['client_browser'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['client_browser'] ) ) : '',
    189                         'automation_flags'       => isset( $_POST['automation_flags'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['automation_flags'] ) ) : '',
    190                         'landing_page'           => isset( $_POST['landing_page'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['landing_page'] ) ) : '',
    191                         'session_first_referrer' => isset( $_POST['session_first_referrer'] ) ? esc_url_raw( wp_unslash( (string) $_POST['session_first_referrer'] ) ) : '',
    192                         'visitor_id'             => isset( $_POST['visitor_id'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['visitor_id'] ) ) : '',
    193                         'session_id'             => isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['session_id'] ) ) : '',
    194                         'pageview_id'            => isset( $_POST['pageview_id'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['pageview_id'] ) ) : '',
    195                 ];
    196                 // phpcs:enable WordPress.Security.NonceVerification.Missing
     179                // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The nonce or public beacon token was verified above.
     180                $payload = self::beacon_payload( wp_unslash( $_POST ) );
    197181
    198182                $result = self::handle_beacon_payload( $payload );
    … …  
    224208                }
    225209
    226                 $result = self::handle_beacon_payload( $payload );
     210                $result = self::handle_beacon_payload( self::beacon_payload( $payload ) );
    227211                if ( ! $result['ok'] ) {
    228212                        return new \WP_REST_Response( [ 'success' => false, 'message' => (string) $result['message'] ], (int) $result['status'] );
    … …  
    243227                }
    244228
    245                 $nonce = isset( $payload['nonce'] ) ? sanitize_text_field( wp_unslash( (string) $payload['nonce'] ) ) : '';
    246                 $token = isset( $payload['token'] ) ? sanitize_text_field( wp_unslash( (string) $payload['token'] ) ) : '';
     229                $nonce = self::beacon_field( $payload, 'nonce' );
     230                $token = self::beacon_field( $payload, 'token' );
    247231                if ( ! wp_verify_nonce( $nonce, 'vtrt_beacon' ) && ! self::verify_public_beacon_token( $token ) ) {
    248232                        return [ 'ok' => false, 'status' => 403, 'message' => 'invalid_token', 'data' => [] ];
    … …  
    254238                }
    255239
    256                 $user_agent     = self::server_text( 'HTTP_USER_AGENT' );
    257                 $posted_path    = isset( $payload['path'] ) ? sanitize_text_field( wp_unslash( (string) $payload['path'] ) ) : '';
    258                 $posted_ref     = isset( $payload['referrer'] ) ? esc_url_raw( wp_unslash( (string) $payload['referrer'] ) ) : '';
    259                 $client_browser   = isset( $payload['client_browser'] ) ? sanitize_text_field( wp_unslash( (string) $payload['client_browser'] ) ) : '';
    260                 $automation_flags = isset( $payload['automation_flags'] ) ? sanitize_text_field( wp_unslash( (string) $payload['automation_flags'] ) ) : '';
    261                 $posted_landing   = isset( $payload['landing_page'] ) ? sanitize_text_field( wp_unslash( (string) $payload['landing_page'] ) ) : '';
    262                 $posted_first   = isset( $payload['session_first_referrer'] ) ? esc_url_raw( wp_unslash( (string) $payload['session_first_referrer'] ) ) : '';
    263                 $visitor_id     = self::clean_identifier( (string) ( $payload['visitor_id'] ?? '' ) );
    264                 $session_id     = self::clean_identifier( (string) ( $payload['session_id'] ?? '' ) );
    265                 $pageview_id    = self::clean_identifier( (string) ( $payload['pageview_id'] ?? '' ) );
    266                 $path           = self::clean_path( $posted_path ?: self::current_path() );
    267                 if ( self::is_non_page_path( $path ) ) {
     240                $user_agent       = self::server_text( 'HTTP_USER_AGENT' );
     241                $posted_path      = self::beacon_field( $payload, 'path' );
     242                $posted_ref       = self::beacon_field( $payload, 'referrer' );
     243                $client_browser   = self::beacon_field( $payload, 'client_browser' );
     244                $automation_flags = self::beacon_field( $payload, 'automation_flags' );
     245                $posted_landing   = self::beacon_field( $payload, 'landing_page' );
     246                $posted_first     = self::beacon_field( $payload, 'session_first_referrer' );
     247                $visitor_id       = self::clean_identifier( self::beacon_field( $payload, 'visitor_id' ) );
     248                $session_id       = self::clean_identifier( self::beacon_field( $payload, 'session_id' ) );
     249                $pageview_id      = self::clean_identifier( self::beacon_field( $payload, 'pageview_id' ) );
     250                $path             = self::clean_path( $posted_path );
     251                if ( 0 !== strpos( $posted_path, '/' ) || 0 === strpos( $posted_path, '//' ) || self::is_non_page_path( $path ) ) {
    268252                        return [ 'ok' => true, 'status' => 200, 'message' => '', 'data' => [ 'tracked' => false, 'ignored' => true ] ];
    269253                }
     254
    270255                $landing_page   = self::clean_path( $posted_landing ?: $path );
    271256                $referrer       = self::clean_referrer( $posted_ref );
    … …  
    273258                $detection      = self::detect( $user_agent, $ip );
    274259
    275                 // A bot/tool user-agent stays a bot even if it posts to the endpoint.
    276                 // A JS beacon alone is not enough to prove a real human because headless
    277                 // browsers and scanners can execute JavaScript too.
    278                 if ( ! in_array( $detection['visitor_type'], [ 'verified_bot', 'suspected_bot' ], true ) ) {
    279                         $infrastructure_detection = self::detect_browser_infrastructure_ip( $ip, $user_agent );
    280                         if ( $infrastructure_detection ) {
    281                                 $detection = $infrastructure_detection;
    282                         } else {
    283                                 $automation_detection = self::detect_client_automation( $automation_flags, $user_agent );
    284                                 if ( $automation_detection ) {
    285                                         $detection = $automation_detection;
    286                                 } else {
    287                                         $behavior_detection = self::detect_js_scan_behavior( $ip, $user_agent, $path, $referrer, $landing_page, $first_referrer, $visitor_id, $session_id );
    288                                         if ( $behavior_detection ) {
    289                                                 $detection = $behavior_detection;
    290                                         } else {
    291                                                 $detection = [
    292                                                         'visitor_type'   => 'human',
    293                                                         'bot_name'       => '',
    294                                                         'bot_reason'     => 'js_beacon_browser_signal_after_safety_checks',
    295                                                         'bot_confidence' => 5,
    296                                                 ];
    297                                         }
    298                                 }
    299                         }
    300                 }
    301 
    302                 $attribution_sig = substr( md5( $referrer . '|' . $landing_page . '|' . $first_referrer ), 0, 12 );
    303 
    304260                if ( '' !== $pageview_id && self::pageview_exists( $pageview_id ) ) {
    305261                        do_action( 'vtrt/visit/skipped', 'pageview_duplicate', [ 'pageview_id' => $pageview_id, 'visitor_id' => $visitor_id, 'session_id' => $session_id, 'path' => $path ] );
    … …  
    307263                }
    308264
    309                 if ( '' === $pageview_id && self::is_duplicate_recent( $ip, self::dedupe_path( $path ), self::ua_sig( $user_agent ), (string) $detection['visitor_type'], 120, $attribution_sig ) ) {
     265                if ( 'unknown' === $detection['visitor_type'] ) {
     266                        $detection = self::detect_client_automation( $automation_flags, $user_agent )
     267                                ?: self::detect_browser_infrastructure_ip( $ip, $user_agent )
     268                                ?: self::detect_js_scan_behavior( self::storage_ip( $ip ), $user_agent, $path, $referrer, $landing_page, $first_referrer, $visitor_id, $session_id )
     269                                ?: self::detect_direct_hosted_browser( $ip, $user_agent, $path, $landing_page, $referrer, $first_referrer, is_user_logged_in() )
     270                                ?: [
     271                                'visitor_type'   => 'human',
     272                                'bot_name'       => '',
     273                                'bot_reason'     => 'js_beacon_browser_signal_after_safety_checks',
     274                                'bot_confidence' => 5,
     275                        ];
     276                }
     277
     278                $attribution_sig = substr( md5( $referrer . '|' . $landing_page . '|' . $first_referrer ), 0, 12 );
     279                if ( '' === $pageview_id && self::is_duplicate_recent( self::storage_ip( $ip ), $path, self::ua_sig( $user_agent ), (string) $detection['visitor_type'], 120, $attribution_sig ) ) {
    310280                        do_action( 'vtrt/visit/skipped', 'legacy_duplicate', [ 'visitor_id' => $visitor_id, 'session_id' => $session_id, 'path' => $path ] );
    311281                        return [ 'ok' => true, 'status' => 200, 'message' => '', 'data' => [ 'tracked' => false, 'duplicate' => true ] ];
    … …  
    313283
    314284                $recorded = self::record_visit( $ip, $user_agent, $path, $referrer, $detection, 'js', 1, $client_browser, $landing_page, $first_referrer, $visitor_id, $session_id, $pageview_id, $payload );
    315                 return [ 'ok' => true, 'status' => 200, 'message' => '', 'data' => [ 'tracked' => $recorded, 'pageview_id' => $pageview_id ] ];
     285                $duplicate = ! $recorded && '' !== $pageview_id && self::pageview_exists( $pageview_id );
     286                return [ 'ok' => true, 'status' => 200, 'message' => '', 'data' => [ 'tracked' => $recorded, 'duplicate' => $duplicate, 'pageview_id' => $pageview_id ] ];
    316287        }
    317288
    … …  
    341312                if ( ! $is_beacon ) {
    342313                        $path = self::current_path();
    343                         if ( preg_match( '#/(wp-json|wp-admin|wp-login\.php|xmlrpc\.php)(/|$)#i', $path ) ) {
    344                                 return true;
    345                         }
    346                         if ( preg_match( '#/(robots\.txt|favicon\.ico|sitemap[^/]*\.xml|\.well-known/traffic-advice)$#i', $path ) ) {
    347                                 return true;
    348                         }
    349314                        if ( self::is_non_page_path( $path ) ) {
    350315                                return true;
    … …  
    367332
    368333        private static function is_non_page_path( string $path ) : bool {
    369                 $path = strtolower( trim( $path ) );
    370                 return '' !== $path && (bool) preg_match( '#\.(?:json|webmanifest|xml|txt|ico)(?:\?.*)?$#i', $path );
     334                $parts = wp_parse_url( $path );
     335                $route = rawurldecode( (string) ( $parts['path'] ?? '' ) );
     336                $query = (string) ( $parts['query'] ?? '' );
     337                return (bool) preg_match( '#/(?:wp-json|wp-admin|wp-login\.php|xmlrpc\.php)(?:/|$)#i', $route )
     338                        || (bool) preg_match( '#\.(?:json|webmanifest|xml|txt|ico|css|js|map|png|jpe?g|gif|webp|avif|svg|woff2?|pdf)(?:/)?$#i', $route )
     339                        || (bool) preg_match( '/(?:^|&)rest_route=/', $query )
     340                        || (bool) preg_match( '#/\.well-known/traffic-advice/?$#i', $route );
     341        }
     342
     343        public static function is_client_ip_excluded() : bool {
     344                return self::is_excluded_ip( self::client_ip() );
    371345        }
    372346
    373347        private static function is_excluded_ip( string $ip ) : bool {
    374                 return in_array( $ip, (array) get_option( 'vtrt_excluded_ips', [] ), true );
     348                return '' !== $ip && self::ip_matches_any( $ip, (array) get_option( 'vtrt_excluded_ips', [] ) );
    375349        }
    376350
    … …  
    385359
    386360        private static function record_visit( string $ip, string $user_agent, string $path, string $raw_referrer, array $detection, string $method, int $js_verified, string $client_browser = '', string $landing_page = '', string $session_first_referrer = '', string $visitor_id = '', string $session_id = '', string $pageview_id = '', array $payload = [] ) : bool {
     361                $ip           = self::storage_ip( $ip );
    387362                $landing_page = self::clean_path( $landing_page ?: $path );
    388363                $referrer     = self::parse_referrer( $raw_referrer, $path, $landing_page, $session_first_referrer, $user_agent, $client_browser );
    389364                $country      = null;
    390365                $city         = null;
    391                 $is_bot       = in_array( $detection['visitor_type'], [ 'verified_bot', 'suspected_bot' ], true ) ? 1 : 0;
     366                $is_bot       = in_array( $detection['visitor_type'], [ 'verified_bot', 'suspected_bot', 'quarantined_bot' ], true ) ? 1 : 0;
    392367                $should_record = (bool) apply_filters(
    393368                        'vtrt/should_record_visit',
    … …  
    436411                        'visitor_id'             => $visitor_id,
    437412                        'session_id'             => $session_id,
    438                         'pageview_id'            => $pageview_id,
     413                        'pageview_id'            => '' !== $pageview_id ? $pageview_id : null,
    439414                ];
    440415
    … …  
    452427                if ( $row_id > 0 ) {
    453428                        self::merge_logged_in_identity( $visitor_id, $session_id );
    454                         self::schedule_geo_lookup( $row_id, $ip );
     429                        if ( self::is_geolocatable_ip( $ip ) ) {
     430                                self::queue_geo_batch( 5 );
     431                        }
    455432                        do_action( 'vtrt/visit/recorded', $row_id, $row, [ 'payload' => $payload, 'detection' => $detection ] );
    456433                        return true;
    … …  
    478455                        $data   = self::fit_lengths( $data, false );
    479456                        $result = $wpdb->insert( $wpdb->prefix . 'vtrt_visitors', $data, $formats );
    480                         if ( false === $result ) {
     457                        if ( false === $result && ( empty( $data['pageview_id'] ) || ! self::pageview_exists( $data['pageview_id'] ) ) ) {
    481458                                // Fallback for sites whose table could not be widened: retry with the
    482459                                // legacy 255-character limit on the URL columns.
    … …  
    515492        }
    516493
    517         private static function schedule_geo_lookup( int $row_id, string $ip ) : void {
    518                 if ( $row_id < 1 || '' === $ip || ! (bool) get_option( 'vtrt_enable_geoip', false ) ) {
     494        private static function geo_enabled() : bool {
     495                return (bool) get_option( 'vtrt_enable_geoip', true ) && ! (bool) get_option( 'vtrt_anonymize_ip', false );
     496        }
     497
     498        /** Schedule a shared background batch, including delayed retries for missing cities. */
     499        private static function queue_geo_batch( int $delay ) : void {
     500                if ( ! self::geo_enabled() ) {
    519501                        return;
    520502                }
    521 
    522                 if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
     503                $scheduled = wp_next_scheduled( self::GEO_HOOK );
     504                $target = time() + max( 5, $delay );
     505                if ( false === $scheduled ) {
     506                        wp_schedule_single_event( $target, self::GEO_HOOK );
     507                } elseif ( $scheduled > $target + 60 && wp_unschedule_event( $scheduled, self::GEO_HOOK ) ) {
     508                        wp_schedule_single_event( $target, self::GEO_HOOK );
     509                }
     510        }
     511
     512        /** Enrich recent incomplete records, or handle one pending event from an older build. */
     513        public static function enrich_geo( int $row_id = 0, string $ip = '' ) : void {
     514                if ( ! self::geo_enabled() ) {
    523515                        return;
    524516                }
    525 
    526                 // Batch GeoIP work into one lightweight cron job instead of scheduling one
    527                 // event per page view. This keeps front-end visits and WP-Cron lighter.
    528                 if ( ! wp_next_scheduled( self::GEO_HOOK ) ) {
    529                         wp_schedule_single_event( time() + 5, self::GEO_HOOK );
    530                 }
    531         }
    532 
    533         /**
    534          * Enrich one old single-event row or batch-enrich recent unresolved rows.
    535          */
    536         public static function enrich_geo( int $row_id = 0, string $ip = '' ) : void {
    537                 if ( ! (bool) get_option( 'vtrt_enable_geoip', true ) ) {
    538                         return;
    539                 }
    540                 if ( ! class_exists( '\Codersly\VTRT\GeoIP' ) && defined( 'VTRT_PATH' ) ) {
    541                         $geo_file = VTRT_PATH . 'includes/GeoIP.php';
    542                         if ( is_file( $geo_file ) ) {
    543                                 require_once $geo_file;
    544                         }
    545                 }
    546                 if ( ! class_exists( '\Codersly\VTRT\GeoIP' ) ) {
    547                         return;
    548                 }
    549 
    550                 if ( $row_id > 0 && '' !== $ip ) {
    551                         self::enrich_single_geo( $row_id, $ip );
    552                         return;
    553                 }
    554 
     517                vtrt_require( 'includes/GeoIP.php' );
     518                $rows = self::geo_rows( $row_id, $ip );
     519                foreach ( $rows as $row ) {
     520                        self::enrich_single_geo( $row );
     521                }
     522
     523                $next = self::geo_rows();
     524                if ( [] !== $next ) {
     525                        self::queue_geo_batch( 60 );
     526                } else {
     527                        $retry = self::next_geo_retry();
     528                        if ( $retry > 0 ) {
     529                                self::queue_geo_batch( max( 60, $retry - time() ) );
     530                        }
     531                }
     532        }
     533
     534        private static function next_geo_retry() : int {
     535                global $wpdb;
     536                $now = gmdate( 'Y-m-d H:i:s' );
     537                $since = current_datetime()->modify( '-' . self::GEO_BACKFILL_DAYS . ' days' )->format( 'Y-m-d H:i:s' );
     538                $until = gmdate( 'Y-m-d H:i:s', time() + DAY_IN_SECONDS );
     539                $next = $wpdb->get_var( $wpdb->prepare(
     540                        "SELECT MIN(geo_retry_at) FROM %i WHERE ts >= %s AND (city IS NULL OR city = '') AND geo_retry_at > %s AND geo_retry_at <= %s AND ip <> ''",
     541                        $wpdb->prefix . 'vtrt_visitors', $since, $now, $until
     542                ) );
     543                return is_string( $next ) && '' !== $next ? (int) strtotime( $next . ' UTC' ) : 0;
     544        }
     545
     546        private static function geo_rows( int $row_id = 0, string $ip = '' ) : array {
    555547                global $wpdb;
    556548                $table = $wpdb->prefix . 'vtrt_visitors';
    557                 $rows  = $wpdb->get_results(
    558                         $wpdb->prepare(
    559                                 "SELECT id, ip FROM %i WHERE (country IS NULL OR country = '') AND ip <> '' ORDER BY id DESC LIMIT %d",
    560                                 $table,
    561                                 12
    562                         ),
    563                         ARRAY_A
    564                 );
    565 
    566                 foreach ( is_array( $rows ) ? $rows : [] as $row ) {
    567                         self::enrich_single_geo( (int) $row['id'], (string) $row['ip'] );
    568                 }
    569         }
    570 
    571 
    572         private static function enrich_single_geo( int $row_id, string $ip ) : void {
    573                 if ( $row_id < 1 || ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
    574                         return;
    575                 }
    576 
    577                 $geo = GeoIP::resolve( $ip );
    578                 if ( ! is_array( $geo ) || ! empty( $geo['_failed'] ) ) {
    579                         return;
    580                 }
    581 
    582                 $data = [
    583                         'country'      => sanitize_text_field( (string) ( $geo['country'] ?? '' ) ),
    584                         'country_code' => strtoupper( substr( sanitize_text_field( (string) ( $geo['country_code'] ?? '' ) ), 0, 2 ) ),
    585                         'region'       => sanitize_text_field( (string) ( $geo['region'] ?? '' ) ),
    586                         'city'         => sanitize_text_field( (string) ( $geo['city'] ?? '' ) ),
    587                         'timezone'     => sanitize_text_field( (string) ( $geo['timezone'] ?? '' ) ),
    588                 ];
    589                 if ( '' === $data['country'] && '' === $data['city'] ) {
    590                         return;
    591                 }
    592 
     549                $since = current_datetime()->modify( '-' . self::GEO_BACKFILL_DAYS . ' days' )->format( 'Y-m-d H:i:s' );
     550                $now = gmdate( 'Y-m-d H:i:s' );
     551
     552                if ( $row_id > 0 ) {
     553                        $sql = $wpdb->prepare(
     554                                "SELECT id, ip, country_code, city FROM %i WHERE id = %d AND ip = %s AND (city IS NULL OR city = '') AND (geo_retry_at IS NULL OR geo_retry_at <= %s)",
     555                                $table, $row_id, $ip, $now
     556                        );
     557                } else {
     558                        $sql = $wpdb->prepare(
     559                                "SELECT id, ip, country_code, city FROM %i WHERE ts >= %s AND (city IS NULL OR city = '') AND (geo_retry_at IS NULL OR geo_retry_at <= %s) AND ip <> '' ORDER BY ts ASC, id ASC LIMIT %d",
     560                                $table, $since, $now, self::GEO_BATCH_LIMIT
     561                        );
     562                }
     563                $rows = $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Both statements are prepared above.
     564                return is_array( $rows ) ? $rows : [];
     565        }
     566
     567        private static function enrich_single_geo( array $row ) : void {
    593568                global $wpdb;
     569                $ip = (string) $row['ip'];
     570                $geo = null;
     571                $retry = 31 * DAY_IN_SECONDS;
     572                if ( self::is_geolocatable_ip( $ip ) && ! self::ip_matches_any( $ip, self::cloudflare_proxy_ranges() ) ) {
     573                        $geo = GeoIP::resolve( $ip );
     574                        $retry = is_array( $geo ) ? DAY_IN_SECONDS : HOUR_IN_SECONDS;
     575                        if ( null === $geo && GeoIP::quota_exhausted( $ip ) ) {
     576                                $retry = max( $retry, strtotime( 'tomorrow 00:00 UTC' ) - time() );
     577                        }
     578                }
     579                $data = [ 'geo_retry_at' => gmdate( 'Y-m-d H:i:s', time() + $retry ) ];
     580                if ( is_array( $geo ) ) {
     581                        $existing_code = strtoupper( (string) ( $row['country_code'] ?? '' ) );
     582                        // Do not replace an existing country using a conflicting lookup.
     583                        if ( '' === $existing_code || $existing_code === $geo['country_code'] ) {
     584                                $data += array_intersect_key( $geo, array_flip( [ 'country', 'country_code', 'region', 'city', 'timezone', 'geo_source' ] ) );
     585                        }
     586                }
     587
    594588                $wpdb->update(
    595589                        $wpdb->prefix . 'vtrt_visitors',
    596590                        $data,
    597                         [ 'id' => $row_id ],
    598                         [ '%s', '%s', '%s', '%s', '%s' ],
    599                         [ '%d' ]
     591                        [ 'id' => (int) $row['id'], 'ip' => $ip, 'country_code' => $row['country_code'], 'city' => $row['city'] ],
     592                        array_fill( 0, count( $data ), '%s' ),
     593                        [ '%d', '%s', '%s', '%s' ]
    600594                );
    601595        }
    … …  
    622616
    623617
    624         private static function is_bot_like_user_agent( string $user_agent ) : bool {
    625                 $ua = strtolower( trim( $user_agent ) );
    626                 if ( '' === $ua ) {
    627                         return true;
    628                 }
    629                 foreach ( [
    630                         'bot', 'spider', 'crawler', 'crawl', 'slurp', 'preview', 'curl', 'wget',
    631                         'python-requests', 'python-urllib', 'go-http-client', 'httpclient', 'libwww-perl',
    632                         'headless', 'phantomjs', 'facebookexternalhit', 'google-inspectiontool',
    633                 ] as $needle ) {
    634                         if ( false !== strpos( $ua, $needle ) ) {
    635                                 return true;
    636                         }
    637                 }
    638                 return false;
     618        public static function classify_current_user_agent() : array {
     619                return self::detect( self::server_text( 'HTTP_USER_AGENT' ), self::client_ip() );
    639620        }
    640621
    … …  
    659640                        'google-inspectiontool'=> 'Google Inspection Tool / Google',
    660641                        'bingbot'             => 'Bingbot / Microsoft',
     642                        'bingpreview'         => 'Bing Preview / Microsoft',
     643                        'facebookcatalog'     => 'Facebook Catalog / Meta',
     644                        'meta-externalagent'  => 'Meta External Agent / Meta',
     645                        'meta-externalfetcher'=> 'Meta External Fetcher / Meta',
    661646                        'duckduckbot'         => 'DuckDuckBot / DuckDuckGo',
    662647                        'facebookexternalhit' => 'Facebook Bot / Meta',
    … …  
    671656                        'gptbot'              => 'GPTBot / OpenAI',
    672657                        'chatgpt-user'        => 'ChatGPT User / OpenAI',
     658                        'oai-searchbot'       => 'OAI-SearchBot / OpenAI',
     659                        'claude-user'         => 'Claude User / Anthropic',
     660                        'claude-searchbot'    => 'Claude SearchBot / Anthropic',
     661                        'perplexity-user'     => 'Perplexity User / Perplexity',
    673662                        'claudebot'           => 'ClaudeBot / Anthropic',
    674663                        'bytespider'          => 'Bytespider / ByteDance',
    … …  
    699688                        'headless'         => 'Headless Browser / Automation',
    700689                        'phantomjs'        => 'PhantomJS / Automation',
     690                        'playwright'       => 'Playwright / Automation',
     691                        'puppeteer'        => 'Puppeteer / Automation',
     692                        'litespeed crawler'=> 'LiteSpeed Cache / Crawler',
     693                        'wp rocket/'       => 'WP Rocket / Cache preloader',
     694                        'wordpress/'       => 'WordPress / HTTP client',
    701695                        'bot'              => 'Unknown Bot / Unknown company',
    702696                        'spider'           => 'Unknown Spider / Unknown company',
    … …  
    708702
    709703                foreach ( $tools as $needle => $name ) {
    710                         if ( false !== strpos( $ua, $needle ) ) {
     704                        $matched = in_array( $needle, [ 'bot', 'crawl', 'preview' ], true )
     705                                ? (bool) preg_match( '/(?:^|[^a-z])' . $needle . '(?:[^a-z]|$)/', $ua )
     706                                        || ( 'bot' === $needle && (bool) preg_match( '/[a-z]bot(?:[\/; )]|$)/', $ua ) )
     707                                : false !== strpos( $ua, $needle );
     708                        if ( $matched ) {
    711709                                $out = [ 'visitor_type' => 'suspected_bot', 'bot_name' => $name, 'bot_reason' => 'bot_or_tool_user_agent_identified', 'bot_confidence' => 75 ];
    712710                                return (array) apply_filters( 'vtrt/bot/detection', $out, $user_agent, $ip );
    … …  
    714712                }
    715713
    716                 if ( preg_match( '/mozilla\/|applewebkit|chrome\/|safari\/|firefox\/|edg\/|mobile/', $ua ) ) {
    717                         $out = [ 'visitor_type' => 'unknown', 'bot_name' => '', 'bot_reason' => 'browser_user_agent_unverified', 'bot_confidence' => 35 ];
     714                if ( ! preg_match( '/applewebkit\/|chrome\/|safari\/|firefox\/|edg\/|opera[\/ ]|msie |trident\//', $ua ) ) {
     715                        $out = [ 'visitor_type' => 'suspected_bot', 'bot_name' => 'Unknown HTTP client', 'bot_reason' => 'non_browser_user_agent', 'bot_confidence' => 70 ];
    718716                }
    719717
    … …  
    731729
    732730                $ua = strtolower( $user_agent );
    733                 if ( in_array( 'webdriver', $flags, true ) || in_array( 'headless_ua', $flags, true ) || in_array( 'automation_globals', $flags, true ) || false !== strpos( $ua, 'headlesschrome' ) ) {
     731                if ( in_array( 'webdriver', $flags, true ) || in_array( 'headless_ua', $flags, true ) || in_array( 'automation_global', $flags, true ) || in_array( 'automation_globals', $flags, true ) || false !== strpos( $ua, 'headlesschrome' ) ) {
    734732                        return [
    735733                                'visitor_type'   => 'suspected_bot',
    … …  
    754752                }
    755753
    756                 $ranges = (array) apply_filters(
    757                         'vtrt/search_infrastructure_ip_ranges',
    758                         [
    759                                 // Common Googlebot / Google renderer / Google proxy ranges often seen as browser-like traffic.
    760                                 '66.249.0.0/16',
    761                                 '66.102.0.0/20',
    762                                 '74.125.0.0/16',
    763                                 '64.233.160.0/19',
    764                                 '72.14.192.0/18',
    765                                 '108.177.0.0/17',
    766                                 '142.250.0.0/15',
    767                                 '172.217.0.0/16',
    768                                 '172.253.0.0/16',
    769                                 '173.194.0.0/16',
    770                                 '209.85.128.0/17',
    771                                 '216.239.32.0/19',
    772                                 '192.178.0.0/15',
    773                                 '2607:f8b0::/32',
    774                                 '2a00:1450::/32',
    775                         ]
    776                 );
     754                // Use only explicitly configured crawler networks. Broad provider ranges
     755                // also carry browser, VPN and proxy traffic from real visitors.
     756                $ranges = (array) apply_filters( 'vtrt/search_infrastructure_ip_ranges', [] );
    777757
    778758                foreach ( $ranges as $cidr ) {
    … …  
    791771        }
    792772
     773        /** A hosted browser is suspicious only with the matching guest/direct request profile. */
     774        private static function detect_direct_hosted_browser( string $ip, string $user_agent, string $path, string $landing_page, string $referrer, string $first_referrer, bool $logged_in = false ) : array {
     775                if ( '' !== $referrer || '' !== $first_referrer || ! preg_match( '#(?:Chrome|Chromium|Firefox|Safari|Edg|OPR)/#i', $user_agent )
     776                        || preg_match( '/Android|Mobile|iPhone|iPad|iPod|FBAN|FBAV|Instagram|WhatsApp/i', $user_agent )
     777                        || $logged_in ) {
     778                        return [];
     779                }
     780
     781                // Narrow Tencent AS132203 hosting subnets observed in browser-like traffic.
     782                // Sources: https://ipregistry.co/AS132203/150.109.0.0/16
     783                // https://ipregistry.co/AS132203/124.156.0.0/16 and https://ip.network/company/tencent-ip-ranges-list
     784                $ranges = (array) apply_filters( 'vtrt/bot/browser_hosting_ranges', [ '150.109.50.0/24', '124.156.179.0/24', '43.129.29.0/24' ] );
     785                $reason = self::ip_matches_any( $ip, array_filter( $ranges, 'is_string' ) )
     786                        ? 'direct_guest_desktop_browser_from_hosting_range'
     787                        : self::hosted_action_reason( $ip, $path );
     788                if ( '' === $reason || self::has_paid_or_social_context( $path, $landing_page, $referrer, $first_referrer ) ) {
     789                        return [];
     790                }
     791
     792                return [
     793                        'visitor_type'   => 'suspected_bot',
     794                        'bot_name'       => 'Possible browser automation / Hosting network',
     795                        'bot_reason'     => $reason,
     796                        'bot_confidence' => 65,
     797                ];
     798        }
     799
     800        /** Re-evaluate stored visits using their own context, never the reviewing admin's identity. */
     801        public static function review_hosted_visit( array $row ) : array {
     802                if ( ! in_array( (string) ( $row['visitor_type'] ?? '' ), [ 'human', 'unknown' ], true )
     803                        || ! empty( $row['is_bot'] ) || ! empty( $row['user_id'] ) || ! empty( $row['user'] )
     804                        || 'js' !== ( $row['tracking_method'] ?? '' )
     805                        || ! in_array( (string) ( $row['referrer_type'] ?? '' ), [ '', 'direct', 'direct_unknown' ], true )
     806                        || ! empty( $row['referrer_host'] ) || ! empty( $row['click_id'] ) || ! empty( $row['platform'] ) ) {
     807                        return [];
     808                }
     809                $path = (string) ( $row['path'] ?? '' );
     810                return self::detect_direct_hosted_browser(
     811                        (string) ( $row['ip'] ?? '' ), (string) ( $row['agent'] ?? '' ), $path,
     812                        (string) ( $row['landing_page'] ?? $path ),
     813                        (string) ( ( $row['referrer_raw'] ?? '' ) ?: ( $row['origin'] ?? '' ) ),
     814                        (string) ( $row['session_first_referrer'] ?? '' )
     815                );
     816        }
     817
     818        /** Require a matching login/catalog route before considering broader hosting ranges. */
     819        private static function hosted_action_reason( string $ip, string $path ) : string {
     820                $route       = (string) wp_parse_url( $path, PHP_URL_PATH );
     821                $login       = preg_match( '#/(?:[a-z0-9_-]+-)?login(?:\.php)?/?$#iD', $route );
     822                $pagination  = preg_match( '#/page/(?:[2-9]|[1-9][0-9]+)/?$#D', $route );
     823                $params = self::referrer_params( $path );
     824                if ( $login && '' !== wp_validate_redirect( $params['redirect_to'] ?? '', '' ) ) {
     825                        $reason = 'direct_login_redirect_from_hosting_range';
     826                } elseif ( $pagination && preg_match( '/^[1-9][0-9]*$/D', $params['add-to-compare'] ?? '' ) ) {
     827                        $reason = 'direct_catalog_action_from_hosting_range';
     828                } elseif ( self::is_catalog_archive( $route, $params ) ) {
     829                        $reason = 'direct_catalog_archive_from_hosting_range';
     830                } else {
     831                        return '';
     832                }
     833
     834                static $default_ranges = null;
     835                if ( null === $default_ranges ) {
     836                        $default_ranges = require __DIR__ . '/data/tencent-hosting-ranges.php';
     837                }
     838                $ranges = (array) apply_filters( 'vtrt/bot/browser_hosting_action_ranges', $default_ranges );
     839                return self::ip_matches_any( $ip, array_filter( $ranges, 'is_string' ) ) ? $reason : '';
     840        }
     841
     842        /** Product taxonomy entry pages also occur in rotating-IP crawls, without action parameters. */
     843        private static function is_catalog_archive( string $route, array $params ) : bool {
     844                $base = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH );
     845                $route = rawurldecode( $route );
     846                $base = rawurldecode( $base );
     847                if ( '/' !== $base && '' !== $base ) {
     848                        if ( 0 !== strpos( $route, $base ) ) {
     849                                return false;
     850                        }
     851                        $route = '/' . ltrim( substr( $route, strlen( $base ) ), '/' );
     852                }
     853                $route = preg_replace( '#^/index\.php/#', '/', $route );
     854                $permalinks = (array) get_option( 'woocommerce_permalinks', [] );
     855                foreach ( [ 'tag_base' => 'product-tag', 'category_base' => 'product-category' ] as $key => $fallback ) {
     856                        $slug = isset( $permalinks[ $key ] ) && is_string( $permalinks[ $key ] ) ? trim( $permalinks[ $key ], '/' ) : '';
     857                        $slug = rawurldecode( $slug ?: $fallback );
     858                        if ( preg_match( '#^/' . preg_quote( $slug, '#' ) . '/[^/]+(?:/.*)?$#uD', $route ) ) {
     859                                return true;
     860                        }
     861                }
     862                return '' !== ( $params['product_tag'] ?? '' ) || '' !== ( $params['product_cat'] ?? '' );
     863        }
    793864
    794865        private static function detect_js_scan_behavior( string $ip, string $user_agent, string $path, string $referrer, string $landing_page, string $first_referrer, string $visitor_id = '', string $session_id = '' ) : array {
    … …  
    800871                }
    801872
    802                 // Keep query-string landing/search pages a little safer from false positives.
    803                 if ( self::path_has_query_string( $path ) || self::path_has_query_string( $landing_page ) ) {
    804                         return [];
    805                 }
    806 
    807                 $stats = self::recent_js_stats( $ip, $user_agent, 90, $visitor_id, $session_id );
    808                 $hits  = (int) ( $stats['hits'] ?? 0 );
    809                 $pages = (int) ( $stats['pages'] ?? 0 );
    810 
    811                 // Referrer should not fully disable bot checks. A JS-capable crawler may carry
    812                 // a search/social referrer while rapidly opening many pages.
    813                 if ( $hits >= 12 || $pages >= 8 ) {
    814                         return [
     873                $identity = $visitor_id ?: $session_id;
     874                $key = '' !== $identity ? 'vtrt_scan_' . md5( $ip . '|' . $identity ) : '';
     875                if ( '' !== $key ) {
     876                        $cached = get_transient( $key );
     877                        if ( is_array( $cached ) ) {
     878                                return $cached;
     879                        }
     880                }
     881
     882                $detection = [];
     883                $stats = '' !== $identity ? self::recent_js_stats( $ip, $user_agent, 30, $visitor_id, $session_id ) : [];
     884                if ( (int) ( $stats['pages'] ?? 0 ) >= 10 ) {
     885                        $detection = [
    815886                                'visitor_type'   => 'suspected_bot',
    816887                                'bot_name'       => 'JS-capable scanner / High-rate page scan',
    … …  
    818889                                'bot_confidence' => ( '' === $referrer && '' === $first_referrer ) ? 84 : 78,
    819890                        ];
    820                 }
    821 
    822                 if ( self::looks_like_pagination_crawl( $ip, $user_agent, $path, 120, $visitor_id, $session_id ) ) {
    823                         return [
     891                } elseif ( self::looks_like_pagination_crawl( $ip, $user_agent, $path, 45, $visitor_id, $session_id )
     892                        || ( '' !== $identity && '' === $referrer && '' === $first_referrer && self::looks_like_pagination_crawl( $ip, $user_agent, $path, 30 ) ) ) {
     893                        $detection = [
    824894                                'visitor_type'   => 'suspected_bot',
    825895                                'bot_name'       => 'JS-capable scanner / Pagination crawl',
    … …  
    828898                        ];
    829899                }
    830 
     900                if ( [] !== $detection && '' !== $key ) {
     901                        set_transient( $key, $detection, 5 * MINUTE_IN_SECONDS );
     902                }
     903                return $detection;
     904        }
     905
     906        private static function recent_js_stats( string $ip, string $user_agent, int $seconds, string $visitor_id, string $session_id ) : array {
     907                global $wpdb;
     908                $since = current_datetime()->modify( '-' . $seconds . ' seconds' )->format( 'Y-m-d H:i:s' );
     909                $column = '' !== $visitor_id ? 'visitor_id' : 'session_id';
     910                $row = $wpdb->get_row(
     911                        $wpdb->prepare(
     912                                "SELECT COUNT(DISTINCT SUBSTRING_INDEX(path, '?', 1)) AS pages FROM %i WHERE %i = %s AND ip = %s AND agent = %s AND ts >= %s AND tracking_method = 'js'",
     913                                $wpdb->prefix . 'vtrt_visitors', $column, $visitor_id ?: $session_id, $ip, $user_agent, $since
     914                        ),
     915                        ARRAY_A
     916                );
     917                return is_array( $row ) ? $row : [];
     918        }
     919
     920        private static function looks_like_pagination_crawl( string $ip, string $user_agent, string $path, int $seconds, string $visitor_id = '', string $session_id = '' ) : bool {
     921                $current = self::pagination_page( $path );
     922                if ( [] === $current || $current[1] < 6 ) {
     923                        return false;
     924                }
     925
     926                $paths = self::recent_js_paths( $ip, $user_agent, $seconds, $visitor_id, $session_id );
     927                $expected = $current[1] - 1;
     928                foreach ( $paths as $old_path ) {
     929                        $previous = self::pagination_page( $old_path );
     930                        if ( $previous === [ $current[0], $expected ] ) {
     931                                --$expected;
     932                                if ( $current[1] - $expected > 5 ) {
     933                                        return true;
     934                                }
     935                        }
     936                }
     937                return false;
     938        }
     939
     940        private static function pagination_page( string $path ) : array {
     941                $clean = self::dedupe_path( $path );
     942                if ( preg_match( '#^(.*/)page/([1-9]\d*)/?$#i', $clean, $matches ) ) {
     943                        return [ $matches[1], (int) $matches[2] ];
     944                }
    831945                return [];
    832946        }
    833947
    834         private static function recent_js_stats( string $ip, string $user_agent, int $seconds, string $visitor_id = '', string $session_id = '' ) : array {
     948        private static function recent_js_paths( string $ip, string $user_agent, int $seconds, string $visitor_id, string $session_id ) : array {
    835949                global $wpdb;
    836950                $table = $wpdb->prefix . 'vtrt_visitors';
    837                 $since = current_datetime()->modify( '-' . max( 10, $seconds ) . ' seconds' )->format( 'Y-m-d H:i:s' );
    838 
    839                 if ( '' !== $session_id ) {
    840                         $row = $wpdb->get_row(
    841                                 $wpdb->prepare(
    842                                         "SELECT COUNT(*) AS hits, COUNT(DISTINCT path) AS pages FROM %i WHERE session_id = %s AND ts >= %s AND tracking_method = 'js'",
    843                                         $table,
    844                                         $session_id,
    845                                         $since
    846                                 ),
    847                                 ARRAY_A
    848                         );
    849                 } elseif ( '' !== $visitor_id ) {
    850                         $row = $wpdb->get_row(
    851                                 $wpdb->prepare(
    852                                         "SELECT COUNT(*) AS hits, COUNT(DISTINCT path) AS pages FROM %i WHERE visitor_id = %s AND ts >= %s AND tracking_method = 'js'",
    853                                         $table,
    854                                         $visitor_id,
    855                                         $since
    856                                 ),
    857                                 ARRAY_A
    858                         );
     951                $since = current_datetime()->modify( '-' . $seconds . ' seconds' )->format( 'Y-m-d H:i:s' );
     952                $identity = $visitor_id ?: $session_id;
     953                if ( '' !== $identity ) {
     954                        $rows = $wpdb->get_col( $wpdb->prepare(
     955                                "SELECT path FROM %i WHERE %i = %s AND ip = %s AND agent = %s AND ts >= %s AND tracking_method = 'js' ORDER BY id DESC LIMIT 12",
     956                                $table, '' !== $visitor_id ? 'visitor_id' : 'session_id', $identity, $ip, $user_agent, $since
     957                        ) );
    859958                } else {
    860                         $row = $wpdb->get_row(
    861                                 $wpdb->prepare(
    862                                         "SELECT COUNT(*) AS hits, COUNT(DISTINCT path) AS pages FROM %i WHERE ip = %s AND agent = %s AND ts >= %s AND tracking_method = 'js'",
    863                                         $table,
    864                                         $ip,
    865                                         $user_agent,
    866                                         $since
    867                                 ),
    868                                 ARRAY_A
    869                         );
    870                 }
    871 
    872                 return is_array( $row ) ? $row : [ 'hits' => 0, 'pages' => 0 ];
    873         }
    874 
    875         private static function looks_like_pagination_crawl( string $ip, string $user_agent, string $path, int $seconds, string $visitor_id = '', string $session_id = '' ) : bool {
    876                 $current_base = self::pagination_base( $path );
    877                 if ( '' === $current_base ) {
    878                         return false;
    879                 }
    880 
    881                 $paths = self::recent_js_paths( $ip, $user_agent, $seconds, 10, $visitor_id, $session_id );
    882                 $matches = 0;
    883                 foreach ( $paths as $old_path ) {
    884                         if ( self::pagination_base( $old_path ) === $current_base ) {
    885                                 $matches++;
    886                         }
    887                 }
    888 
    889                 // The current request has not been inserted yet. Five previous matching
    890                 // pagination pages means this is at least the sixth rapid page in sequence.
    891                 return $matches >= 5;
    892         }
    893 
    894         private static function pagination_base( string $path ) : string {
    895                 $clean = self::dedupe_path( $path );
    896                 if ( preg_match( '#^(.*/)?page/(\d+)/?$#i', $clean, $m ) ) {
    897                         return rtrim( (string) $m[1], '/' ) . '/page/';
    898                 }
    899                 return '';
    900         }
    901 
    902         private static function recent_js_paths( string $ip, string $user_agent, int $seconds, int $limit, string $visitor_id = '', string $session_id = '' ) : array {
    903                 global $wpdb;
    904                 $table = $wpdb->prefix . 'vtrt_visitors';
    905                 $since = current_datetime()->modify( '-' . max( 10, $seconds ) . ' seconds' )->format( 'Y-m-d H:i:s' );
    906                 $limit = max( 1, $limit );
    907 
    908                 if ( '' !== $session_id ) {
    909                         $rows = $wpdb->get_col(
    910                                 $wpdb->prepare(
    911                                         "SELECT path FROM %i WHERE session_id = %s AND ts >= %s AND tracking_method = 'js' ORDER BY id DESC LIMIT %d",
    912                                         $table,
    913                                         $session_id,
    914                                         $since,
    915                                         $limit
    916                                 )
    917                         );
    918                 } elseif ( '' !== $visitor_id ) {
    919                         $rows = $wpdb->get_col(
    920                                 $wpdb->prepare(
    921                                         "SELECT path FROM %i WHERE visitor_id = %s AND ts >= %s AND tracking_method = 'js' ORDER BY id DESC LIMIT %d",
    922                                         $table,
    923                                         $visitor_id,
    924                                         $since,
    925                                         $limit
    926                                 )
    927                         );
    928                 } else {
    929                         $rows = $wpdb->get_col(
    930                                 $wpdb->prepare(
    931                                         "SELECT path FROM %i WHERE ip = %s AND agent = %s AND ts >= %s AND tracking_method = 'js' ORDER BY id DESC LIMIT %d",
    932                                         $table,
    933                                         $ip,
    934                                         $user_agent,
    935                                         $since,
    936                                         $limit
    937                                 )
    938                         );
    939                 }
    940 
     959                        $rows = $wpdb->get_col( $wpdb->prepare(
     960                                "SELECT path FROM %i WHERE ip = %s AND agent = %s AND ts >= %s AND tracking_method = 'js' AND referrer_type = 'direct_unknown' ORDER BY id DESC LIMIT 12",
     961                                $table, $ip, $user_agent, $since
     962                        ) );
     963                }
    941964                return is_array( $rows ) ? array_map( 'strval', $rows ) : [];
    942965        }
    … …  
    947970                }
    948971
    949                 [ $network, $prefix ] = array_pad( explode( '/', $cidr, 2 ), 2, '' );
     972                [ $network, $prefix ] = explode( '/', $cidr, 2 );
     973                if ( ! preg_match( '/^\d{1,3}$/D', $prefix ) ) {
     974                        return false;
     975                }
    950976                $prefix = (int) $prefix;
    951977                $ip_bin = @inet_pton( $ip );
    … …  
    956982
    957983                $bits = strlen( $ip_bin ) * 8;
    958                 if ( $prefix < 0 || $prefix > $bits ) {
     984                if ( $prefix < 1 || $prefix > $bits ) {
    959985                        return false;
    960986                }
    … …  
    9781004
    9791005        private static function has_paid_or_social_context( string $path, string $landing_page, string $referrer, string $first_referrer ) : bool {
    980                 unset( $referrer, $first_referrer );
    981                 $joined = strtolower( $path . ' ' . $landing_page );
    982                 if ( preg_match( '/(?:[?&](?:fbclid|gclid|gbraid|wbraid|ttclid)=)|[?&](?:utm_source|source)=(?:facebook|instagram|meta|tiktok|google|youtube)(?:&|$)/', $joined ) ) {
    983                         return true;
    984                 }
    985 
    986                 foreach ( [ 'vtrt_fbclid', 'vtrt_gclid', 'vtrt_gbraid', 'vtrt_wbraid', 'vtrt_ttclid' ] as $cookie_key ) {
    987                         if ( isset( $_COOKIE[ $cookie_key ] ) && '' !== trim( sanitize_text_field( wp_unslash( (string) $_COOKIE[ $cookie_key ] ) ) ) ) {
     1006                foreach ( [ $referrer, $first_referrer ] as $url ) {
     1007                        $host = (string) wp_parse_url( $url, PHP_URL_HOST );
     1008                        if ( 'social' === self::referrer_host_type( strtolower( rtrim( $host, '.' ) ) ) ) {
    9881009                                return true;
    9891010                        }
    9901011                }
     1012                foreach ( [ $path, $landing_page ] as $candidate ) {
     1013                        $params = self::referrer_params( $candidate );
     1014                        if ( self::has_paid_params( $params ) || 'social' === ( $params['utm_medium'] ?? '' ) ) {
     1015                                return true;
     1016                        }
     1017                }
    9911018                return false;
    9921019        }
    9931020
    994         /**
    995          * Recover a social/search referrer from UTM or click-tracking query parameters.
    996          * This is needed for networks such as LinkedIn that may intentionally hide the
    997          * browser referrer on some clicks.
    998          */
    999         private static function infer_referrer_from_url_params( string $path, string $landing_page = '' ) : string {
    1000                 foreach ( [ $landing_page, $path ] as $candidate ) {
    1001                         $candidate = (string) $candidate;
    1002                         if ( '' === $candidate ) {
    1003                                 continue;
    1004                         }
    1005 
    1006                         $query = (string) wp_parse_url( $candidate, PHP_URL_QUERY );
    1007                         if ( '' === $query ) {
    1008                                 continue;
    1009                         }
    1010 
    1011                         $params = [];
    1012                         parse_str( $query, $params );
    1013                         if ( ! is_array( $params ) || ! $params ) {
    1014                                 continue;
    1015                         }
    1016 
    1017                         $normalized = [];
    1018                         foreach ( $params as $key => $value ) {
    1019                                 $key = strtolower( sanitize_key( (string) $key ) );
    1020                                 if ( is_scalar( $value ) ) {
    1021                                         $normalized[ $key ] = strtolower( sanitize_text_field( (string) $value ) );
    1022                                 }
    1023                         }
    1024 
    1025                         $source   = $normalized['utm_source'] ?? ( $normalized['source'] ?? ( $normalized['ref'] ?? ( $normalized['referrer'] ?? '' ) ) );
    1026                         $medium   = $normalized['utm_medium'] ?? '';
    1027                         $campaign = $normalized['utm_campaign'] ?? '';
    1028                         $joined   = trim( $source . ' ' . $medium . ' ' . $campaign );
    1029 
    1030                         $referrer = self::social_referrer_from_tokens( $joined, $normalized );
    1031                         if ( '' !== $referrer ) {
    1032                                 return $referrer;
    1033                         }
    1034                 }
    1035 
    1036                 return '';
     1021        private static function has_paid_params( array $params ) : bool {
     1022                foreach ( [ 'fbclid', 'gclid', 'gbraid', 'wbraid', 'ttclid', 'msclkid', 'li_fat_id' ] as $key ) {
     1023                        if ( ! empty( $params[ $key ] ) ) {
     1024                                return true;
     1025                        }
     1026                }
     1027                return self::has_paid_medium( $params );
     1028        }
     1029
     1030        private static function has_paid_medium( array $params ) : bool {
     1031                return in_array( $params['utm_medium'] ?? '', [ 'cpc', 'ppc', 'paid', 'paid_social', 'paid_search' ], true );
     1032        }
     1033
     1034        private static function referrer_params( string $path ) : array {
     1035                $params = [];
     1036                parse_str( (string) wp_parse_url( $path, PHP_URL_QUERY ), $params );
     1037                $normalized = [];
     1038                foreach ( $params as $key => $value ) {
     1039                        if ( is_string( $value ) ) {
     1040                                $normalized[ strtolower( (string) $key ) ] = strtolower( trim( $value ) );
     1041                        }
     1042                }
     1043                return $normalized;
    10371044        }
    10381045
    … …  
    10461053                }
    10471054
    1048                 if ( preg_match( '/linkedinapp|linkedin/', $haystack ) ) {
     1055                if ( preg_match( '/linkedinapp|\blinkedin\b/', $haystack ) ) {
    10491056                        return 'https://www.linkedin.com/';
    10501057                }
    1051                 if ( preg_match( '/fban|fbav|fb_iab|facebook/', $haystack ) ) {
     1058                if ( preg_match( '/fban\/|fbav\/|fb_iab|facebook app/', $haystack ) ) {
    10521059                        return 'https://www.facebook.com/';
    10531060                }
    … …  
    10611068                        return 'https://www.tiktok.com/';
    10621069                }
    1063 
     1070                if ( preg_match( '/whatsapp/', $haystack ) ) {
     1071                        return 'https://www.whatsapp.com/';
     1072                }
    10641073                return '';
    10651074        }
    … …  
    10701079         * @param array<string,string> $params Normalized query parameters.
    10711080         */
    1072         private static function social_referrer_from_tokens( string $tokens, array $params ) : string {
    1073                 $tokens = strtolower( $tokens );
    1074 
    1075                 if ( preg_match( '/linkedin|linked\s*in|lnkd|lnkd\.in/', $tokens ) || isset( $params['li_fat_id'] ) || isset( $params['lipi'] ) || isset( $params['trk'] ) ) {
    1076                         return 'https://www.linkedin.com/';
    1077                 }
    1078                 if ( preg_match( '/facebook|meta|fb/', $tokens ) || isset( $params['fbclid'] ) ) {
    1079                         return 'https://www.facebook.com/';
    1080                 }
    1081                 if ( preg_match( '/instagram|ig/', $tokens ) ) {
    1082                         return 'https://www.instagram.com/';
    1083                 }
    1084                 if ( preg_match( '/twitter|x\.com|x-twitter/', $tokens ) ) {
    1085                         return 'https://x.com/';
    1086                 }
    1087                 if ( preg_match( '/youtube|youtu\.be/', $tokens ) ) {
    1088                         return 'https://www.youtube.com/';
    1089                 }
    1090                 if ( preg_match( '/tiktok/', $tokens ) ) {
    1091                         return 'https://www.tiktok.com/';
    1092                 }
    1093                 if ( preg_match( '/reddit/', $tokens ) ) {
    1094                         return 'https://www.reddit.com/';
    1095                 }
    1096                 if ( preg_match( '/pinterest/', $tokens ) ) {
    1097                         return 'https://www.pinterest.com/';
    1098                 }
    1099                 if ( preg_match( '/google/', $tokens ) || isset( $params['gclid'] ) ) {
    1100                         return 'https://www.google.com/';
    1101                 }
    1102                 if ( preg_match( '/bing/', $tokens ) || isset( $params['msclkid'] ) ) {
    1103                         return 'https://www.bing.com/';
    1104                 }
    1105 
     1081        private static function infer_referrer_from_url_params( string $path ) : string {
     1082                $params = self::referrer_params( $path );
     1083                $source = $params['utm_source'] ?? $params['source'] ?? $params['ref'] ?? $params['referrer'] ?? '';
     1084                $sources = [
     1085                        'facebook' => 'www.facebook.com', 'meta' => 'www.facebook.com', 'fb' => 'www.facebook.com',
     1086                        'instagram' => 'www.instagram.com', 'ig' => 'www.instagram.com',
     1087                        'linkedin' => 'www.linkedin.com', 'lnkd' => 'www.linkedin.com', 'lnkd.in' => 'www.linkedin.com',
     1088                        'twitter' => 'x.com', 'x' => 'x.com', 'x.com' => 'x.com', 'x-twitter' => 'x.com',
     1089                        'youtube' => 'www.youtube.com', 'youtu.be' => 'www.youtube.com', 'tiktok' => 'www.tiktok.com',
     1090                        'reddit' => 'www.reddit.com', 'pinterest' => 'www.pinterest.com', 'whatsapp' => 'www.whatsapp.com',
     1091                        'google' => 'www.google.com', 'bing' => 'www.bing.com', 'duckduckgo' => 'duckduckgo.com',
     1092                        'yahoo' => 'search.yahoo.com', 'yandex' => 'yandex.com', 'baidu' => 'www.baidu.com',
     1093                ];
     1094                if ( isset( $sources[ $source ] ) ) {
     1095                        return 'https://' . $sources[ $source ] . '/';
     1096                }
     1097
     1098                $host = (string) wp_parse_url( 'https://' . $source, PHP_URL_HOST );
     1099                if ( $source === $host && in_array( self::referrer_host_type( $host ), [ 'search', 'social' ], true ) ) {
     1100                        return 'https://' . $host . '/';
     1101                }
     1102
     1103                $click_sources = [
     1104                        'fbclid' => 'www.facebook.com', 'gclid' => 'www.google.com', 'gbraid' => 'www.google.com',
     1105                        'wbraid' => 'www.google.com', 'msclkid' => 'www.bing.com', 'ttclid' => 'www.tiktok.com',
     1106                        'li_fat_id' => 'www.linkedin.com', 'lipi' => 'www.linkedin.com',
     1107                ];
     1108                foreach ( $click_sources as $key => $domain ) {
     1109                        if ( ! empty( $params[ $key ] ) ) {
     1110                                return 'https://' . $domain . '/';
     1111                        }
     1112                }
    11061113                return '';
    11071114        }
    11081115
    1109         private static function path_has_query_string( string $path ) : bool {
    1110                 $parts = wp_parse_url( $path );
    1111                 return ! empty( $parts['query'] );
    1112         }
    1113 
     1116        private static function referrer_host_type( string $host ) : string {
     1117                if ( preg_match( '/(?:^|\.)(?:google\.(?:com|[a-z]{2}|(?:co|com)\.[a-z]{2})|bing\.com|yahoo\.(?:com|co\.jp)|duckduckgo\.com|yandex\.(?:ru|com)|baidu\.com)$/D', $host ) ) {
     1118                        return 'search';
     1119                }
     1120                if ( preg_match( '/(?:^|\.)(?:facebook\.com|fb\.com|instagram\.com|t\.co|twitter\.com|x\.com|linkedin\.com|lnkd\.in|youtube\.com|youtu\.be|tiktok\.com|reddit\.com|pinterest\.com|whatsapp\.com|wa\.me)$/D', $host ) ) {
     1121                        return 'social';
     1122                }
     1123                return 'referral';
     1124        }
    11141125
    11151126        private static function parse_referrer( string $raw, string $path, string $landing_page = '', string $session_first_referrer = '', string $user_agent = '', string $client_browser = '' ) : array {
    1116                 $path         = self::clean_path( $path ?: '/' );
    1117                 $landing_page = self::clean_path( $landing_page ?: $path );
    1118                 $first_raw    = self::clean_referrer( $session_first_referrer );
    1119                 $raw          = self::clean_referrer( $raw );
    1120 
    1121                 // Some social apps/networks hide the HTTP referrer. When the real
    1122                 // referrer is empty, recover attribution from campaign parameters
    1123                 // first, then from clearly identifiable in-app browser user agents.
    1124                 if ( '' === $raw ) {
    1125                         $raw = self::infer_referrer_from_url_params( $path, $landing_page );
    1126                 }
    1127                 if ( '' === $raw ) {
    1128                         $raw = self::infer_referrer_from_client_context( $user_agent, $client_browser );
    1129                 }
    1130 
    1131                 $site         = strtolower( preg_replace( '/^www\./', '', (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) );
    1132                 // Keep the current page's real referrer separate from the session's first
    1133                 // external source. This prevents a previous Google/Bing visit from being
    1134                 // shown as the referrer for later direct or internal page views.
    1135                 $host   = $raw ? strtolower( preg_replace( '/^www\./', '', (string) wp_parse_url( $raw, PHP_URL_HOST ) ) ) : '';
    1136                 $type   = 'direct_unknown';
    1137                 $source = '';
     1127                $raw = self::clean_referrer( $raw );
     1128                $first = self::clean_referrer( $session_first_referrer );
     1129                $site = preg_replace( '/^www\./', '', strtolower( rtrim( (string) wp_parse_url( home_url(), PHP_URL_HOST ), '.' ) ) );
     1130                $first_host = preg_replace( '/^www\./', '', strtolower( rtrim( (string) wp_parse_url( $first, PHP_URL_HOST ), '.' ) ) );
     1131                if ( $first_host === $site ) {
     1132                        $first = '';
     1133                }
     1134
     1135                // Store the observed URL separately from inferred campaign/app attribution.
     1136                $source_url = $raw ?: self::infer_referrer_from_url_params( $path );
     1137                $source_url = $source_url ?: self::infer_referrer_from_client_context( $user_agent, $client_browser );
     1138                $host = preg_replace( '/^www\./', '', strtolower( rtrim( (string) wp_parse_url( $source_url, PHP_URL_HOST ), '.' ) ) );
     1139                $type = 'direct_unknown';
    11381140                $medium = '';
    1139 
    1140                 if ( $host ) {
    1141                         if ( $host === $site ) {
    1142                                 $type   = 'internal';
    1143                                 $source = $host;
    1144                                 $medium = 'internal';
    1145                         } elseif ( preg_match( '/google\.|bing\.|yahoo\.|duckduckgo\.|yandex\.|baidu\./', $host ) ) {
    1146                                 $type   = 'search';
    1147                                 $source = $host;
    1148                                 $medium = 'organic';
    1149                         } elseif ( preg_match( '/facebook\.com|l\.facebook\.com|lm\.facebook\.com|instagram\.com|l\.instagram\.com|t\.co|twitter\.com|x\.com|linkedin\.com|youtube\.com|youtu\.be|tiktok\.com|reddit\.com|pinterest\.com/', $host ) ) {
    1150                                 $type   = 'social';
    1151                                 $source = $host;
    1152                                 $medium = 'referral';
    1153                         } else {
    1154                                 $type   = 'referral';
    1155                                 $source = $host;
    1156                                 $medium = 'referral';
    1157                         }
    1158                 }
    1159 
    1160                 $first = $first_raw;
    1161                 if ( '' === $first && in_array( $type, [ 'referral', 'search', 'social' ], true ) ) {
    1162                         $first = $raw ?: $source;
     1141                if ( '' !== $host ) {
     1142                        $type = $site === $host ? 'internal' : self::referrer_host_type( $host );
     1143                        $medium = 'search' === $type ? 'organic' : ( 'internal' === $type ? 'internal' : 'referral' );
     1144                        $params = self::referrer_params( $path );
     1145                        if ( 'internal' !== $type && ( self::has_paid_medium( $params )
     1146                                || array_filter( array_intersect_key( $params, array_flip( [ 'gclid', 'gbraid', 'wbraid', 'ttclid', 'msclkid' ] ) ) ) ) ) {
     1147                                $medium = 'paid';
     1148                        }
     1149                        if ( '' === $first && 'internal' !== $type && '' !== $raw ) {
     1150                                $first = $raw;
     1151                        }
    11631152                }
    11641153
    … …  
    11671156                        'referrer_host'          => $host,
    11681157                        'referrer_type'          => $type,
    1169                         'referrer_source'        => $source,
     1158                        'referrer_source'        => $host,
    11701159                        'referrer_medium'        => $medium,
    1171                         'landing_page'           => $landing_page,
     1160                        'landing_page'           => self::clean_path( $landing_page ?: $path ),
    11721161                        'session_first_referrer' => $first,
    11731162                ];
    … …  
    12261215        }
    12271216
    1228         private static function clean( $value ) : string {
    1229                 if ( is_array( $value ) ) {
    1230                         $value = reset( $value );
    1231                 }
    1232                 return substr( sanitize_text_field( wp_unslash( (string) $value ) ), 0, 191 );
    1233         }
    1234 
    1235 
    1236 
    12371217        /**
    12381218         * Return the resolved visitor IP after applying trusted-proxy rules.
    … …  
    12401220         */
    12411221        public static function resolved_client_ip() : string {
    1242                 return self::client_ip();
     1222                return self::storage_ip( self::client_ip() );
    12431223        }
    12441224
    12451225        private static function client_ip() : string {
    12461226                $remote = self::valid_ip( self::server_text( 'REMOTE_ADDR' ) );
    1247                 $ip     = $remote;
    1248 
    1249                 $stored_proxies  = (array) get_option( 'vtrt_trusted_proxy_ips', [] );
    1250                 $trusted_proxies = array_filter( array_map( 'trim', (array) apply_filters( 'vtrt/trusted_proxy_ips', $stored_proxies ) ) );
    1251                 $remote_trusted  = '' !== $remote && self::ip_matches_any( $remote, $trusted_proxies );
    1252                 $remote_private  = '' === $remote || ! filter_var( $remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE );
    1253 
    1254                 // Cloudflare supplies a single canonical address. CF-Ray is checked so a
    1255                 // random client header is not preferred on ordinary direct requests.
    1256                 if ( '' !== self::server_text( 'HTTP_CF_RAY' ) ) {
    1257                         $cf = self::valid_public_ip( self::server_text( 'HTTP_CF_CONNECTING_IP' ) );
    1258                         if ( '' !== $cf ) {
    1259                                 $ip = $cf;
    1260                         }
    1261                 }
    1262 
    1263                 if ( $ip === $remote && ( $remote_trusted || $remote_private ) ) {
    1264                         foreach ( [ 'HTTP_TRUE_CLIENT_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR' ] as $header ) {
    1265                                 $value = self::server_text( $header );
    1266                                 if ( '' === $value ) {
     1227                if ( '' === $remote ) {
     1228                        return '';
     1229                }
     1230                $cloudflare = self::cloudflare_proxy_ranges();
     1231                $trusted = (array) apply_filters( 'vtrt/trusted_proxy_ips', (array) get_option( 'vtrt_trusted_proxy_ips', [] ) );
     1232
     1233                if ( self::ip_matches_any( $remote, $cloudflare ) ) {
     1234                        return self::cloudflare_client_ip( $cloudflare );
     1235                }
     1236                if ( ! self::ip_matches_any( $remote, $trusted ) ) {
     1237                        return $remote;
     1238                }
     1239
     1240                // The nearest untrusted hop bounds the chain. Never skip an invalid or
     1241                // private untrusted address to accept a client-supplied value to its left.
     1242                $forwarded = self::server_text( 'HTTP_X_FORWARDED_FOR' );
     1243                if ( '' !== $forwarded ) {
     1244                        foreach ( array_reverse( explode( ',', $forwarded ) ) as $part ) {
     1245                                $candidate = self::valid_ip( $part );
     1246                                if ( '' === $candidate ) {
     1247                                        return '';
     1248                                }
     1249                                if ( self::ip_matches_any( $candidate, $cloudflare ) ) {
     1250                                        // Behind an additional trusted proxy, use the verified chain,
     1251                                        // not a Cloudflare-specific header that the last proxy may forward.
    12671252                                        continue;
    12681253                                }
    1269                                 foreach ( explode( ',', $value ) as $candidate ) {
    1270                                         $candidate = self::valid_public_ip( trim( $candidate ) );
    1271                                         if ( '' !== $candidate ) {
    1272                                                 $ip = $candidate;
    1273                                                 break 2;
    1274                                         }
     1254                                if ( ! self::ip_matches_any( $candidate, $trusted ) ) {
     1255                                        return $candidate;
    12751256                                }
    12761257                        }
    1277                 }
    1278 
    1279                 if ( '' === $ip ) {
    12801258                        return '';
    12811259                }
     1260
     1261                // A configured proxy must append X-Forwarded-For or overwrite X-Real-IP.
     1262                $real = self::valid_ip( self::server_text( 'HTTP_X_REAL_IP' ) );
     1263                return self::ip_matches_any( $real, array_merge( $trusted, $cloudflare ) ) ? '' : $real;
     1264        }
     1265
     1266        private static function cloudflare_proxy_ranges() : array {
     1267                // https://www.cloudflare.com/ips/
     1268                return (array) apply_filters( 'vtrt/cloudflare_proxy_ips', [
     1269                        '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '104.16.0.0/13', '104.24.0.0/14',
     1270                        '108.162.192.0/18', '131.0.72.0/22', '141.101.64.0/18', '162.158.0.0/15', '172.64.0.0/13',
     1271                        '173.245.48.0/20', '188.114.96.0/20', '190.93.240.0/20', '197.234.240.0/22', '198.41.128.0/17',
     1272                        '2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32', '2405:8100::/32',
     1273                        '2a06:98c0::/29', '2c0f:f248::/32',
     1274                ] );
     1275        }
     1276
     1277        private static function cloudflare_client_ip( array $ranges ) : string {
     1278                $address = self::valid_ip( self::server_text( 'HTTP_CF_CONNECTING_IP' ) );
     1279                if ( self::ip_in_cidr( $address, '240.0.0.0/4' ) ) {
     1280                        $address = self::valid_ip( self::server_text( 'HTTP_CF_CONNECTING_IPV6' ) );
     1281                }
     1282                return self::is_geolocatable_ip( $address ) && ! self::ip_matches_any( $address, $ranges ) ? $address : '';
     1283        }
     1284
     1285        private static function storage_ip( string $ip ) : string {
    12821286                return (bool) get_option( 'vtrt_anonymize_ip', false ) ? self::anonymize_ip( $ip ) : $ip;
    12831287        }
    1284 
    12851288
    12861289        private static function valid_ip( string $ip ) : string {
    … …  
    12981301                        $ip = substr( $ip, 0, $zone );
    12991302                }
    1300                 return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
    1301         }
    1302 
    1303         private static function valid_public_ip( string $ip ) : string {
     1303                if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
     1304                        return '';
     1305                }
     1306                $packed = inet_pton( $ip );
     1307                if ( 16 === strlen( $packed ) && substr( $packed, 0, 12 ) === str_repeat( "\0", 10 ) . "\xff\xff" ) {
     1308                        $packed = substr( $packed, 12 );
     1309                }
     1310                return (string) inet_ntop( $packed );
     1311        }
     1312
     1313        public static function is_geolocatable_ip( string $ip ) : bool {
    13041314                $ip = self::valid_ip( $ip );
    1305                 return '' !== $ip && filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ? $ip : '';
     1315                if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
     1316                        return false;
     1317                }
     1318                return ! self::ip_matches_any( $ip, [
     1319                        '100.64.0.0/10', '192.0.0.0/24', '192.0.2.0/24', '198.18.0.0/15', '198.51.100.0/24',
     1320                        '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4', '2001:db8::/32', 'ff00::/8',
     1321                ] );
    13061322        }
    13071323
    … …  
    13711387
    13721388        private static function current_path() : string {
    1373                 $uri = self::server_text( 'REQUEST_URI' );
     1389                // Keep percent-encoded path and query values intact for attribution.
     1390                $uri = isset( $_SERVER['REQUEST_URI'] ) && is_string( $_SERVER['REQUEST_URI'] )
     1391                        ? wp_strip_all_tags( wp_unslash( $_SERVER['REQUEST_URI'] ) )
     1392                        : '';
    13741393                return self::clean_path( $uri ?: '/' );
    13751394        }
    13761395
    13771396        private static function clean_path( string $uri ) : string {
    1378                 $uri    = sanitize_text_field( wp_unslash( $uri ) );
     1397                $uri    = trim( (string) preg_replace( '/[\x00-\x1f\x7f]/', '', $uri ) );
    13791398                $parsed = wp_parse_url( $uri );
    13801399                $path   = (string) ( $parsed['path'] ?? '/' );
    1381                 $query  = ! empty( $parsed['query'] ) ? '?' . sanitize_text_field( (string) $parsed['query'] ) : '';
     1400                $query  = isset( $parsed['query'] ) && '' !== $parsed['query'] ? '?' . $parsed['query'] : '';
    13821401                return ( $path ?: '/' ) . $query;
    13831402        }
    13841403
    13851404        private static function referrer() : string {
    1386                 return self::clean_referrer( self::server_text( 'HTTP_REFERER' ) );
     1405                $value = isset( $_SERVER['HTTP_REFERER'] ) && is_string( $_SERVER['HTTP_REFERER'] )
     1406                        ? wp_strip_all_tags( wp_unslash( $_SERVER['HTTP_REFERER'] ) )
     1407                        : '';
     1408                return self::clean_referrer( $value );
    13871409        }
    13881410
    13891411        private static function clean_referrer( string $value ) : string {
    1390                 $value = esc_url_raw( sanitize_url( wp_unslash( $value ) ) );
     1412                if ( ! preg_match( '#^https?://#i', $value ) ) {
     1413                        return '';
     1414                }
     1415                $value = esc_url_raw( $value, [ 'http', 'https' ] );
    13911416                if ( '' === $value ) {
    13921417                        return '';
    13931418                }
    1394                 $home = wp_parse_url( home_url(), PHP_URL_HOST );
    1395                 $host = wp_parse_url( $value, PHP_URL_HOST );
    1396                 if ( ! $host ) {
     1419                $parts = wp_parse_url( $value );
     1420                if ( empty( $parts['host'] ) || isset( $parts['user'] ) || isset( $parts['pass'] )
     1421                        || ! in_array( strtolower( (string) ( $parts['scheme'] ?? '' ) ), [ 'http', 'https' ], true ) ) {
    13971422                        return '';
    13981423                }
    1399                 // Keep same-site and normal external referrers only; discard invalid schemes.
    1400                 $scheme = wp_parse_url( $value, PHP_URL_SCHEME );
    1401                 if ( ! in_array( strtolower( (string) $scheme ), [ 'http', 'https' ], true ) ) {
     1424                return explode( '#', $value, 2 )[0];
     1425        }
     1426
     1427        private static function beacon_payload( array $input ) : array {
     1428                $payload = [];
     1429                foreach ( [ 'nonce', 'token', 'path', 'referrer', 'client_browser', 'automation_flags', 'landing_page',
     1430                        'session_first_referrer', 'visitor_id', 'session_id', 'pageview_id' ] as $key ) {
     1431                        $payload[ $key ] = self::beacon_field( $input, $key );
     1432                }
     1433                return $payload;
     1434        }
     1435
     1436        private static function beacon_field( array $input, string $key ) : string {
     1437                $value = $input[ $key ] ?? '';
     1438                if ( ! is_string( $value ) ) {
    14021439                        return '';
    14031440                }
    1404                 return $value;
     1441                if ( in_array( $key, [ 'path', 'referrer', 'landing_page', 'session_first_referrer' ], true ) ) {
     1442                        return substr( $value, 0, 8192 );
     1443                }
     1444                return sanitize_text_field( substr( $value, 0, 256 ) );
    14051445        }
    14061446
    … …  
    14351475                foreach ( $rules as $rule ) {
    14361476                        $rule = trim( (string) $rule );
    1437                         if ( '' === $rule ) {
     1477                        if ( '' === $rule || '' === $ip ) {
    14381478                                continue;
    14391479                        }
    1440                         if ( $ip === $rule || ( false !== strpos( $rule, '/' ) && self::ip_in_cidr( $ip, $rule ) ) ) {
     1480                        if ( $ip === self::valid_ip( $rule ) || self::ip_in_cidr( $ip, $rule ) ) {
    14411481                                return true;
    14421482                        }
    … …  
    14961536
    14971537        public static function maybe_queue_missing_geo() : void {
    1498                 if ( ! current_user_can( 'manage_options' ) || ! (bool) get_option( 'vtrt_enable_geoip', true ) || wp_next_scheduled( self::GEO_HOOK ) ) {
     1538                if ( ! current_user_can( 'manage_options' ) || ! self::geo_enabled() || wp_next_scheduled( self::GEO_HOOK ) ) {
    14991539                        return;
    15001540                }
    1501                 global $wpdb;
    1502                 $missing = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE (country IS NULL OR country = '') AND ip <> ''", $wpdb->prefix . 'vtrt_visitors' ) );
    1503                 if ( $missing > 0 ) {
    1504                         wp_schedule_single_event( time() + 1, self::GEO_HOOK );
     1541                if ( [] !== self::geo_rows() ) {
     1542                        self::queue_geo_batch( 5 );
    15051543                }
    15061544        }
  • codersly-visitor-tracker/trunk/readme.txt

    r3704726 r3790318  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.1.2
     7Stable tag: 1.1.3
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    5656Supported providers:
    5757
    58 * ipwho.is — Terms: https://ipwhois.io/terms — Privacy: https://ipwhois.io/privacy
    59 * ipapi.co — Terms: https://ipapi.co/terms/ — Privacy: https://ipapi.co/privacy/
    60 * DB-IP — Terms: https://db-ip.com/tos.php — Privacy: https://db-ip.com/privacy.php
     58* ipwho.is — API: https://ipwhois.io/documentation — Terms: https://ipwhois.io/terms — Privacy: https://ipwhois.io/privacy
     59* ipapi.co — API: https://ipapi.co/api/ — Terms: https://ipapi.co/terms/ — Privacy: https://ipapi.co/privacy/
     60* DB-IP — API: https://db-ip.com/api/doc.php — Terms: https://db-ip.com/tos.php — Privacy: https://db-ip.com/privacy.php
    6161
    62 When enabled, the first available provider receives the visitor IP address and may return country, country code, region, city and timezone. Results are cached locally to reduce requests.
     62IPWhois is used by default. The ipapi.co and DB-IP integrations can use HTTPS credentials configured with the `vtrt/geo/ipapico_key` and `vtrt/geo/dbip_key` filters. Enabled services receive only the public IP address for this lookup and may return country, region, city and timezone. GeoIP is paused when IP anonymization is enabled. Results are cached locally to reduce requests. The lookup runs through WordPress Cron, with up to 200 uncached IP lookups per UTC day by default. Pro's IPGeolocation.io lookups use the provider plan's quota separately.
    6363
    6464== Installation ==
    … …  
    7373= Is every implemented feature in this package free? =
    7474
    75 Yes. Every feature implemented by the WordPress.org package works without a license key, payment, trial period, quota, time limit, or upgrade.
     75Yes. The plugin's tracking features work without a plugin license, payment or upgrade. Optional third-party services have their own account requirements and limits.
    7676
    7777= Why are Pro-labelled pages visible? =
    … …  
    9191Yes, in this build it is enabled so the Location column can populate automatically. It can be turned off under Visitors Tracker → Settings.
    9292
     93= Are exclusions kept when reinstalling the plugin? =
     94
     95Deleting the plugin through WordPress keeps the excluded IP addresses, excluded user roles, trusted proxy settings and visitor records for a reinstall. For an intentional data purge, define `VTRT_DROP_TABLES_ON_UNINSTALL` as true or set `vtrt_drop_on_uninstall` before deleting the Free plugin.
     96
    9397== Changelog ==
     98
     99= 1.1.3 =
     100* Validate, unslash and sanitize the bot-review screen and request-method inputs before use, addressing the four reported Plugin Check input warnings.
     101* Recognize anonymous direct desktop visits to product-tag/product-category archives across the bundled hosting networks, including rotated IPv4/IPv6 addresses, custom WooCommerce taxonomy slugs and plain permalinks. Matching visits are suspected bots, not DNS-verified crawlers; country alone is never used.
     102* Add Visitors > Recheck bot labels to review existing records in resumable batches of 100, retaining all records. Login, mobile, referral and paid-context safeguards apply to the stored visit, not the reviewing administrator. No GeoIP API calls are needed.
     103* Reuse a complete city result for the same IP and location source from a separate location cache.
     104* Keep IP and user-role exclusions and trusted proxy settings when the plugin is deleted and reinstalled.
     105* Sanitize request paths and referrers while retaining percent-encoded values.
     106* Resolve IPv4 and IPv6 through recognized Cloudflare edge ranges or configured trusted proxies.
     107* Preserve usable city estimates and retry missing cities in recent records. Conflicting city estimates require a majority among available city results.
     108* Separate the observed referrer URL from inferred campaign and app sources. Preserve encoded paths and query parameters.
     109* Classify named crawlers and browser automation consistently and improve rapid sequential scan checks.
     110* Allow multiple server-tracked bot visits under the unique page-view index.
     111
    94112
    95113= 1.1.2 =
    … …  
    124142* Removed cache-sensitive logged-in state from front-end configuration.
    125143* Uses authenticated/non-authenticated admin-ajax first, with REST fallback.
     144
     145= 1.1.3 =
     146Safer IP resolution, location corroboration and bounded background lookup batches.
  • codersly-visitor-tracker/trunk/uninstall.php

    r3612240 r3790318  
    33 * Uninstall script for Codersly Visitor Tracker.
    44 *
    5  * Removes plugin options and scheduled events. Visitor data is kept by default.
    6  * To also drop the visitor table, define VTRT_DROP_TABLES_ON_UNINSTALL as true
    7  * or set the vtrt_drop_on_uninstall option before uninstalling.
     5 * Clears scheduled work and temporary settings. Visitor data, saved IP locations, exclusion lists
     6 * and trusted proxies are kept for reinstalls. To erase them too, define
     7 * VTRT_DROP_TABLES_ON_UNINSTALL as true or set vtrt_drop_on_uninstall.
    88 *
    99 * @package CoderslyVisitorTracker
    … …  
    2626                'vtrt_enable_tracking',
    2727                'vtrt_enable_geoip',
    28                 'vtrt_excluded_roles',
    29                 'vtrt_excluded_ips',
     28                'vtrt_geo_cursor_113',
     29                'vtrt_geoip_auto_enabled_110',
    3030                'vtrt_anonymize_ip',
    3131                'vtrt_db_version',
    3232                'vtrt_drop_on_uninstall',
    3333        ];
     34
     35        if ( $drop_requested ) {
     36                $option_keys[] = 'vtrt_excluded_roles';
     37                $option_keys[] = 'vtrt_excluded_ips';
     38                $option_keys[] = 'vtrt_trusted_proxy_ips';
     39                $option_keys[] = 'vtrt_geo_cache_initialized';
     40        }
    3441
    3542        foreach ( $option_keys as $option_key ) {
    … …  
    4148        if ( $drop_requested ) {
    4249                $table_visitors = $wpdb->prefix . 'vtrt_visitors';
    43                 $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $table_visitors ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.NoCaching
     50                $table_geo = $wpdb->prefix . 'vtrt_geo_cache';
     51                $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i, %i', $table_visitors, $table_geo ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.NoCaching
    4452        }
    4553}
    4654
    4755if ( is_multisite() ) {
    48         $vtrt_sites = get_sites( [ 'fields' => 'ids' ] );
     56        $vtrt_sites = get_sites( [ 'fields' => 'ids', 'number' => 0 ] );
    4957        if ( is_array( $vtrt_sites ) ) {
    50                 $vtrt_current = get_current_blog_id();
    5158                foreach ( $vtrt_sites as $blog_id ) {
    5259                        switch_to_blog( (int) $blog_id );
    5360                        vtrt_uninstall_cleanup_single_site();
     61                        restore_current_blog();
    5462                }
    55                 switch_to_blog( (int) $vtrt_current );
    5663        }
    5764} else {
Note: See TracChangeset for help on using the changeset viewer.