Changeset 3790318
- Timestamp:
- 11/08/2026 12:50:59 AM (20 hours ago)
- Location:
- codersly-visitor-tracker
- Files:
-
- 36 added
- 3 deleted
- 10 edited
-
tags/1.0.9 (deleted)
-
tags/1.1.0 (deleted)
-
tags/1.1.1 (deleted)
-
tags/1.1.3 (added)
-
tags/1.1.3/assets (added)
-
tags/1.1.3/assets/admin (added)
-
tags/1.1.3/assets/admin/css (added)
-
tags/1.1.3/assets/admin/css/admin.css (added)
-
tags/1.1.3/assets/admin/js (added)
-
tags/1.1.3/assets/admin/js/bot-review.js (added)
-
tags/1.1.3/assets/public (added)
-
tags/1.1.3/assets/public/css (added)
-
tags/1.1.3/assets/public/css/public.css (added)
-
tags/1.1.3/assets/public/js (added)
-
tags/1.1.3/assets/public/js/public.js (added)
-
tags/1.1.3/codersly-visitor-tracker.php (added)
-
tags/1.1.3/includes (added)
-
tags/1.1.3/includes/Admin (added)
-
tags/1.1.3/includes/Admin/Admin.php (added)
-
tags/1.1.3/includes/Admin/Assets.php (added)
-
tags/1.1.3/includes/Admin/Bot_Review.php (added)
-
tags/1.1.3/includes/Admin/List_Table_Visitors.php (added)
-
tags/1.1.3/includes/Admin/Settings_Page.php (added)
-
tags/1.1.3/includes/GDPR.php (added)
-
tags/1.1.3/includes/GeoIP.php (added)
-
tags/1.1.3/includes/Install.php (added)
-
tags/1.1.3/includes/Tracker.php (added)
-
tags/1.1.3/includes/data (added)
-
tags/1.1.3/includes/data/tencent-hosting-ranges.php (added)
-
tags/1.1.3/languages (added)
-
tags/1.1.3/languages/index.php (added)
-
tags/1.1.3/license.txt (added)
-
tags/1.1.3/readme.txt (added)
-
tags/1.1.3/uninstall.php (added)
-
trunk/assets/admin/js (added)
-
trunk/assets/admin/js/bot-review.js (added)
-
trunk/assets/public/js/public.js (modified) (8 diffs)
-
trunk/codersly-visitor-tracker.php (modified) (2 diffs)
-
trunk/includes/Admin/Admin.php (modified) (1 diff)
-
trunk/includes/Admin/Bot_Review.php (added)
-
trunk/includes/Admin/List_Table_Visitors.php (modified) (4 diffs)
-
trunk/includes/Admin/Settings_Page.php (modified) (4 diffs)
-
trunk/includes/GeoIP.php (modified) (8 diffs)
-
trunk/includes/Install.php (modified) (7 diffs)
-
trunk/includes/Tracker.php (modified) (47 diffs)
-
trunk/includes/data (added)
-
trunk/includes/data/tencent-hosting-ranges.php (added)
-
trunk/readme.txt (modified) (5 diffs)
-
trunk/uninstall.php (modified) (3 diffs)
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. */ 2 2 (() => { 3 3 'use strict'; … … 79 79 }; 80 80 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 95 81 const attribution = (() => { 96 82 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 || ''); 99 84 let landing = safeGet(window.sessionStorage, LANDING_KEY); 100 85 let firstReferrer = safeGet(window.sessionStorage, FIRST_REF_KEY); … … 130 115 try { if (navigator.webdriver) flags.push('webdriver'); } catch (e) {} 131 116 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) {} 133 118 return flags.join(','); 134 119 }; … … 172 157 let body = null; 173 158 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 }; 175 160 } catch (e) { return { ok: false, method: 'rest', error: 'network_error' }; } 176 161 }; … … 192 177 let body = null; 193 178 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 }; 195 180 } catch (e) { return { ok: false, method: 'ajax', error: 'network_error' }; } 196 181 }; … … 205 190 206 191 let sent = false; 192 const prerendering = () => document.prerendering === true || document.visibilityState === 'prerender'; 207 193 const send = async () => { 208 if (sent || document.visibilityState === 'prerender') return;194 if (sent || prerendering()) return; 209 195 sent = true; 210 196 // Use admin-ajax first for every visitor. It preserves WordPress login cookies … … 224 210 225 211 const sendOnExit = () => { 226 if (sent ) return;212 if (sent || prerendering()) return; 227 213 sent = true; 228 214 // admin-ajax supports both logged-in and guest requests and keeps the current … … 247 233 248 234 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(); 250 240 window.addEventListener('pagehide', sendOnExit, { once: true, passive: true }); 251 241 document.addEventListener('visibilitychange', () => { -
codersly-visitor-tracker/trunk/codersly-visitor-tracker.php
r3704726 r3790318 3 3 * Plugin Name: Codersly Visitor Tracker 4 4 * Description: Lightweight visitor tracking with bot/referrer recording, privacy controls, and an admin dashboard. 5 * Version: 1.1. 25 * Version: 1.1.3 6 6 * Author: Codersly 7 7 * Author URI: https://codersly.com/ … … 19 19 define('VTRT_PATH', plugin_dir_path(__FILE__)); 20 20 define('VTRT_URL', plugin_dir_url(__FILE__)); 21 define('VTRT_VER', '1.1. 2');21 define('VTRT_VER', '1.1.3'); 22 22 if (!defined('VTRT_TBL_VISITORS')) { define('VTRT_TBL_VISITORS', 'vtrt_visitors'); } 23 23 function 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 27 27 public static function init() : void { 28 28 add_action( 'admin_menu', [ __CLASS__, 'register_menus' ], 10 ); 29 require_once __DIR__ . '/Bot_Review.php'; 30 Bot_Review::init(); 29 31 30 32 $file = __DIR__ . '/Settings_Page.php'; -
codersly-visitor-tracker/trunk/includes/Admin/List_Table_Visitors.php
r3670318 r3790318 122 122 public function column_type( array $item ) : string { 123 123 $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' ); 125 125 $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; 127 129 } 128 130 … … 139 141 140 142 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'] ?? '' ) ); 150 144 $host = (string) ( $item['referrer_host'] ?? '' ); 151 145 $type = (string) ( $item['referrer_type'] ?? '' ); … … 173 167 } 174 168 175 private function bot_company_ referrer_cell( array $item ) : string {169 private function bot_company_cell( array $item ) : string { 176 170 $bot_name = (string) ( $item['bot_name'] ?? '' ); 177 171 $agent = (string) ( $item['agent'] ?? '' ); … … 180 174 if ( '' !== $domain ) { 181 175 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' ); 182 179 } 183 180 return esc_html( $company ); -
codersly-visitor-tracker/trunk/includes/Admin/Settings_Page.php
r3670318 r3790318 79 79 foreach ( (array) $lines as $line ) { 80 80 $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 ) ) { 82 82 $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 } 83 88 } 84 89 } … … 137 142 echo '<input type="hidden" name="vtrt_enable_geoip" value="0">'; 138 143 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>'; 140 145 }, 141 146 'vtrt_settings', … … 143 148 ); 144 149 150 151 do_action( 'vtrt/settings/geoip_fields' ); 145 152 146 153 add_settings_field( … … 150 157 $values = (array) get_option( 'vtrt_trusted_proxy_ips', [] ); 151 158 echo '<textarea name="vtrt_trusted_proxy_ips" rows="5" cols="55" class="large-text code" placeholder="203.0.113.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>'; 153 160 }, 154 161 'vtrt_settings', -
codersly-visitor-tracker/trunk/includes/GeoIP.php
r3670318 r3790318 14 14 } 15 15 16 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching 16 17 final 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 } 19 47 20 48 public static function resolve( string $ip ) : ?array { 21 49 $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 ); 28 60 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 = []; 34 225 foreach ( $providers as $provider ) { 35 $ provider = sanitize_key( (string) $provider );226 $result = null; 36 227 if ( 'ipwho' === $provider ) { 37 228 $result = self::from_ipwho( $ip ); 38 229 } 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 ); 42 233 } 43 234 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; 56 290 } 57 291 58 292 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 ) ) { 62 296 return null; 63 297 } … … 82 316 } 83 317 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 ) ) { 87 322 return null; 88 323 } … … 100 335 } 101 336 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 ) ) { 105 340 return null; 106 341 } … … 118 353 } 119 354 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 } 121 360 $response = wp_remote_get( 122 361 $url, 123 362 [ 124 363 'timeout' => 2.5, 125 'redirection' => 1, 364 'redirection' => 0, 365 'limit_response_size' => 32768, 126 366 'headers' => [ 'Accept' => 'application/json' ], 127 'user-agent' => 'Codersly-Visitor-Tracker/1.1. 0',367 'user-agent' => 'Codersly-Visitor-Tracker/1.1.3', 128 368 ] 129 369 ); … … 132 372 } 133 373 $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 } 134 378 if ( $code < 200 || $code >= 300 ) { 135 379 return null; … … 142 386 $out = [ 143 387 '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'] ?? '' ) ), 145 389 'region' => self::string_value( $data['region'] ?? '', 120 ), 146 390 'city' => self::string_value( $data['city'] ?? '', 120 ), … … 148 392 'provider' => self::string_value( $data['provider'] ?? '', 40 ), 149 393 ]; 150 return ( '' !== $out['country'] || '' !== $out['city'] )? $out : null;151 } 152 153 p rivate 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 ); 155 399 } 156 400 … … 160 404 } 161 405 $value = trim( sanitize_text_field( (string) $value ) ); 406 if ( in_array( strtolower( $value ), [ '-', 'n/a', 'unknown', 'null', 'none', 'not available' ], true ) ) { 407 return ''; 408 } 162 409 return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, $max ) : substr( $value, 0, $max ); 163 410 } -
codersly-visitor-tracker/trunk/includes/Install.php
r3703338 r3790318 17 17 final class Install { 18 18 /** Internal schema marker. */ 19 private const DB_VERSION = '1.1. 1-schema3';19 private const DB_VERSION = '1.1.3-schema6'; 20 20 private const OPT_DB_VERSION = 'vtrt_db_version'; 21 21 … … 26 26 } 27 27 28 public static function deactivate() : void {} 28 public static function deactivate() : void { 29 wp_clear_scheduled_hook( 'vtrt_async_geo_lookup' ); 30 } 29 31 30 32 public static function maybe_upgrade() : void { … … 32 34 self::activate(); 33 35 } 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 ); 34 50 } 35 51 … … 51 67 region VARCHAR(120) NULL, 52 68 city VARCHAR(120) NULL, 69 geo_retry_at DATETIME NULL, 70 geo_source VARCHAR(40) NULL, 53 71 timezone VARCHAR(80) NULL, 54 72 path VARCHAR(2048) NOT NULL DEFAULT '', … … 77 95 KEY ts (ts), 78 96 KEY ip (ip), 97 KEY ip_geo_source (ip,geo_source), 79 98 KEY user_id (user_id), 80 99 KEY is_bot (is_bot), … … 87 106 ) {$charset};"; 88 107 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};" ); 89 123 } 90 124 … … 119 153 private static function run_migrations() : void { 120 154 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 } 128 218 } 129 219 -
codersly-visitor-tracker/trunk/includes/Tracker.php
r3704726 r3790318 18 18 final class Tracker { 19 19 private const GEO_HOOK = 'vtrt_async_geo_lookup'; 20 private const GEO_BATCH_LIMIT = 4; 21 private const GEO_BACKFILL_DAYS = 30; 20 22 21 23 private static ?array $table_columns = null; … … 33 35 34 36 /** 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. 36 38 */ 37 39 public static function enqueue_beacon() : void { … … 41 43 42 44 $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'; 44 46 if ( '' !== $script_path && is_file( $script_path ) ) { 45 47 $modified = filemtime( $script_path ); … … 60 62 'token' => self::public_beacon_token(), 61 63 'delayMs' => (int) apply_filters( 'vtrt/beacon/delay_ms', 0 ), 62 'serverReferrer' => self::referrer(),63 64 ] 64 65 ); … … 94 95 $json = $request->get_json_params(); 95 96 if ( is_array( $json ) && isset( $json['nonce'] ) ) { 96 $nonce = s anitize_text_field( (string) $json['nonce']);97 $nonce = self::beacon_field( $json, 'nonce' ); 97 98 } 98 99 } … … 101 102 $body = $request->get_body_params(); 102 103 if ( is_array( $body ) && isset( $body['nonce'] ) ) { 103 $nonce = s anitize_text_field( (string) $body['nonce']);104 $nonce = self::beacon_field( $body, 'nonce' ); 104 105 } 105 106 } … … 136 137 137 138 $user_agent = self::server_text( 'HTTP_USER_AGENT' ); 138 if ( ! self::is_bot_like_user_agent( $user_agent ) ) {139 return;140 }141 142 139 $ip = self::client_ip(); 143 140 if ( '' === $ip || self::is_excluded_ip( $ip ) || self::is_excluded_user() ) { … … 154 151 } 155 152 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 ) ) { 157 154 return; 158 155 } … … 174 171 */ 175 172 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 ) ) { 179 176 wp_send_json_error( [ 'message' => 'invalid_token' ], 403 ); 180 177 } 181 178 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 ) ); 197 181 198 182 $result = self::handle_beacon_payload( $payload ); … … 224 208 } 225 209 226 $result = self::handle_beacon_payload( $payload);210 $result = self::handle_beacon_payload( self::beacon_payload( $payload ) ); 227 211 if ( ! $result['ok'] ) { 228 212 return new \WP_REST_Response( [ 'success' => false, 'message' => (string) $result['message'] ], (int) $result['status'] ); … … 243 227 } 244 228 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' ); 247 231 if ( ! wp_verify_nonce( $nonce, 'vtrt_beacon' ) && ! self::verify_public_beacon_token( $token ) ) { 248 232 return [ 'ok' => false, 'status' => 403, 'message' => 'invalid_token', 'data' => [] ]; … … 254 238 } 255 239 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 ) ) { 268 252 return [ 'ok' => true, 'status' => 200, 'message' => '', 'data' => [ 'tracked' => false, 'ignored' => true ] ]; 269 253 } 254 270 255 $landing_page = self::clean_path( $posted_landing ?: $path ); 271 256 $referrer = self::clean_referrer( $posted_ref ); … … 273 258 $detection = self::detect( $user_agent, $ip ); 274 259 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 headless277 // 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 304 260 if ( '' !== $pageview_id && self::pageview_exists( $pageview_id ) ) { 305 261 do_action( 'vtrt/visit/skipped', 'pageview_duplicate', [ 'pageview_id' => $pageview_id, 'visitor_id' => $visitor_id, 'session_id' => $session_id, 'path' => $path ] ); … … 307 263 } 308 264 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 ) ) { 310 280 do_action( 'vtrt/visit/skipped', 'legacy_duplicate', [ 'visitor_id' => $visitor_id, 'session_id' => $session_id, 'path' => $path ] ); 311 281 return [ 'ok' => true, 'status' => 200, 'message' => '', 'data' => [ 'tracked' => false, 'duplicate' => true ] ]; … … 313 283 314 284 $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 ] ]; 316 287 } 317 288 … … 341 312 if ( ! $is_beacon ) { 342 313 $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 }349 314 if ( self::is_non_page_path( $path ) ) { 350 315 return true; … … 367 332 368 333 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() ); 371 345 } 372 346 373 347 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', [] ) ); 375 349 } 376 350 … … 385 359 386 360 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 ); 387 362 $landing_page = self::clean_path( $landing_page ?: $path ); 388 363 $referrer = self::parse_referrer( $raw_referrer, $path, $landing_page, $session_first_referrer, $user_agent, $client_browser ); 389 364 $country = null; 390 365 $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; 392 367 $should_record = (bool) apply_filters( 393 368 'vtrt/should_record_visit', … … 436 411 'visitor_id' => $visitor_id, 437 412 'session_id' => $session_id, 438 'pageview_id' => $pageview_id,413 'pageview_id' => '' !== $pageview_id ? $pageview_id : null, 439 414 ]; 440 415 … … 452 427 if ( $row_id > 0 ) { 453 428 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 } 455 432 do_action( 'vtrt/visit/recorded', $row_id, $row, [ 'payload' => $payload, 'detection' => $detection ] ); 456 433 return true; … … 478 455 $data = self::fit_lengths( $data, false ); 479 456 $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'] ) ) ) { 481 458 // Fallback for sites whose table could not be widened: retry with the 482 459 // legacy 255-character limit on the URL columns. … … 515 492 } 516 493 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() ) { 519 501 return; 520 502 } 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() ) { 523 515 return; 524 516 } 525 526 // Batch GeoIP work into one lightweight cron job instead of scheduling one527 // 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 { 555 547 global $wpdb; 556 548 $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 { 593 568 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 594 588 $wpdb->update( 595 589 $wpdb->prefix . 'vtrt_visitors', 596 590 $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' ] 600 594 ); 601 595 } … … 622 616 623 617 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() ); 639 620 } 640 621 … … 659 640 'google-inspectiontool'=> 'Google Inspection Tool / Google', 660 641 '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', 661 646 'duckduckbot' => 'DuckDuckBot / DuckDuckGo', 662 647 'facebookexternalhit' => 'Facebook Bot / Meta', … … 671 656 'gptbot' => 'GPTBot / OpenAI', 672 657 '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', 673 662 'claudebot' => 'ClaudeBot / Anthropic', 674 663 'bytespider' => 'Bytespider / ByteDance', … … 699 688 'headless' => 'Headless Browser / Automation', 700 689 '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', 701 695 'bot' => 'Unknown Bot / Unknown company', 702 696 'spider' => 'Unknown Spider / Unknown company', … … 708 702 709 703 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 ) { 711 709 $out = [ 'visitor_type' => 'suspected_bot', 'bot_name' => $name, 'bot_reason' => 'bot_or_tool_user_agent_identified', 'bot_confidence' => 75 ]; 712 710 return (array) apply_filters( 'vtrt/bot/detection', $out, $user_agent, $ip ); … … 714 712 } 715 713 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 ]; 718 716 } 719 717 … … 731 729 732 730 $ua = strtolower( $user_agent ); 733 if ( in_array( 'webdriver', $flags, true ) || in_array( 'headless_ua', $flags, true ) || in_array( 'automation_global s', $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' ) ) { 734 732 return [ 735 733 'visitor_type' => 'suspected_bot', … … 754 752 } 755 753 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', [] ); 777 757 778 758 foreach ( $ranges as $cidr ) { … … 791 771 } 792 772 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 } 793 864 794 865 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 { … … 800 871 } 801 872 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 carry812 // 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 = [ 815 886 'visitor_type' => 'suspected_bot', 816 887 'bot_name' => 'JS-capable scanner / High-rate page scan', … … 818 889 'bot_confidence' => ( '' === $referrer && '' === $first_referrer ) ? 84 : 78, 819 890 ]; 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 = [ 824 894 'visitor_type' => 'suspected_bot', 825 895 'bot_name' => 'JS-capable scanner / Pagination crawl', … … 828 898 ]; 829 899 } 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 } 831 945 return []; 832 946 } 833 947 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 { 835 949 global $wpdb; 836 950 $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 ) ); 859 958 } 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 } 941 964 return is_array( $rows ) ? array_map( 'strval', $rows ) : []; 942 965 } … … 947 970 } 948 971 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 } 950 976 $prefix = (int) $prefix; 951 977 $ip_bin = @inet_pton( $ip ); … … 956 982 957 983 $bits = strlen( $ip_bin ) * 8; 958 if ( $prefix < 0|| $prefix > $bits ) {984 if ( $prefix < 1 || $prefix > $bits ) { 959 985 return false; 960 986 } … … 978 1004 979 1005 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, '.' ) ) ) ) { 988 1009 return true; 989 1010 } 990 1011 } 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 } 991 1018 return false; 992 1019 } 993 1020 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; 1037 1044 } 1038 1045 … … 1046 1053 } 1047 1054 1048 if ( preg_match( '/linkedinapp| linkedin/', $haystack ) ) {1055 if ( preg_match( '/linkedinapp|\blinkedin\b/', $haystack ) ) { 1049 1056 return 'https://www.linkedin.com/'; 1050 1057 } 1051 if ( preg_match( '/fban |fbav|fb_iab|facebook/', $haystack ) ) {1058 if ( preg_match( '/fban\/|fbav\/|fb_iab|facebook app/', $haystack ) ) { 1052 1059 return 'https://www.facebook.com/'; 1053 1060 } … … 1061 1068 return 'https://www.tiktok.com/'; 1062 1069 } 1063 1070 if ( preg_match( '/whatsapp/', $haystack ) ) { 1071 return 'https://www.whatsapp.com/'; 1072 } 1064 1073 return ''; 1065 1074 } … … 1070 1079 * @param array<string,string> $params Normalized query parameters. 1071 1080 */ 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 } 1106 1113 return ''; 1107 1114 } 1108 1115 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 } 1114 1125 1115 1126 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'; 1138 1140 $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 } 1163 1152 } 1164 1153 … … 1167 1156 'referrer_host' => $host, 1168 1157 'referrer_type' => $type, 1169 'referrer_source' => $ source,1158 'referrer_source' => $host, 1170 1159 'referrer_medium' => $medium, 1171 'landing_page' => $landing_page,1160 'landing_page' => self::clean_path( $landing_page ?: $path ), 1172 1161 'session_first_referrer' => $first, 1173 1162 ]; … … 1226 1215 } 1227 1216 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 1237 1217 /** 1238 1218 * Return the resolved visitor IP after applying trusted-proxy rules. … … 1240 1220 */ 1241 1221 public static function resolved_client_ip() : string { 1242 return self:: client_ip();1222 return self::storage_ip( self::client_ip() ); 1243 1223 } 1244 1224 1245 1225 private static function client_ip() : string { 1246 1226 $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. 1267 1252 continue; 1268 1253 } 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; 1275 1256 } 1276 1257 } 1277 }1278 1279 if ( '' === $ip ) {1280 1258 return ''; 1281 1259 } 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 { 1282 1286 return (bool) get_option( 'vtrt_anonymize_ip', false ) ? self::anonymize_ip( $ip ) : $ip; 1283 1287 } 1284 1285 1288 1286 1289 private static function valid_ip( string $ip ) : string { … … 1298 1301 $ip = substr( $ip, 0, $zone ); 1299 1302 } 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 { 1304 1314 $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 ] ); 1306 1322 } 1307 1323 … … 1371 1387 1372 1388 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 : ''; 1374 1393 return self::clean_path( $uri ?: '/' ); 1375 1394 } 1376 1395 1377 1396 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 ) ); 1379 1398 $parsed = wp_parse_url( $uri ); 1380 1399 $path = (string) ( $parsed['path'] ?? '/' ); 1381 $query = ! empty( $parsed['query'] ) ? '?' . sanitize_text_field( (string) $parsed['query'] ): '';1400 $query = isset( $parsed['query'] ) && '' !== $parsed['query'] ? '?' . $parsed['query'] : ''; 1382 1401 return ( $path ?: '/' ) . $query; 1383 1402 } 1384 1403 1385 1404 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 ); 1387 1409 } 1388 1410 1389 1411 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' ] ); 1391 1416 if ( '' === $value ) { 1392 1417 return ''; 1393 1418 } 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 ) ) { 1397 1422 return ''; 1398 1423 } 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 ) ) { 1402 1439 return ''; 1403 1440 } 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 ) ); 1405 1445 } 1406 1446 … … 1435 1475 foreach ( $rules as $rule ) { 1436 1476 $rule = trim( (string) $rule ); 1437 if ( '' === $rule ) {1477 if ( '' === $rule || '' === $ip ) { 1438 1478 continue; 1439 1479 } 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 ) ) { 1441 1481 return true; 1442 1482 } … … 1496 1536 1497 1537 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 ) ) { 1499 1539 return; 1500 1540 } 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 ); 1505 1543 } 1506 1544 } -
codersly-visitor-tracker/trunk/readme.txt
r3704726 r3790318 5 5 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 1.1. 27 Stable tag: 1.1.3 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 56 56 Supported providers: 57 57 58 * ipwho.is — Terms: https://ipwhois.io/terms — Privacy: https://ipwhois.io/privacy59 * 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.php58 * 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 61 61 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.62 IPWhois 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. 63 63 64 64 == Installation == … … 73 73 = Is every implemented feature in this package free? = 74 74 75 Yes. Every feature implemented by the WordPress.org package works without a license key, payment, trial period, quota, time limit, or upgrade.75 Yes. The plugin's tracking features work without a plugin license, payment or upgrade. Optional third-party services have their own account requirements and limits. 76 76 77 77 = Why are Pro-labelled pages visible? = … … 91 91 Yes, in this build it is enabled so the Location column can populate automatically. It can be turned off under Visitors Tracker → Settings. 92 92 93 = Are exclusions kept when reinstalling the plugin? = 94 95 Deleting 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 93 97 == 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 94 112 95 113 = 1.1.2 = … … 124 142 * Removed cache-sensitive logged-in state from front-end configuration. 125 143 * Uses authenticated/non-authenticated admin-ajax first, with REST fallback. 144 145 = 1.1.3 = 146 Safer IP resolution, location corroboration and bounded background lookup batches. -
codersly-visitor-tracker/trunk/uninstall.php
r3612240 r3790318 3 3 * Uninstall script for Codersly Visitor Tracker. 4 4 * 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 true7 * 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. 8 8 * 9 9 * @package CoderslyVisitorTracker … … 26 26 'vtrt_enable_tracking', 27 27 'vtrt_enable_geoip', 28 'vtrt_ excluded_roles',29 'vtrt_ excluded_ips',28 'vtrt_geo_cursor_113', 29 'vtrt_geoip_auto_enabled_110', 30 30 'vtrt_anonymize_ip', 31 31 'vtrt_db_version', 32 32 'vtrt_drop_on_uninstall', 33 33 ]; 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 } 34 41 35 42 foreach ( $option_keys as $option_key ) { … … 41 48 if ( $drop_requested ) { 42 49 $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 44 52 } 45 53 } 46 54 47 55 if ( is_multisite() ) { 48 $vtrt_sites = get_sites( [ 'fields' => 'ids' ] );56 $vtrt_sites = get_sites( [ 'fields' => 'ids', 'number' => 0 ] ); 49 57 if ( is_array( $vtrt_sites ) ) { 50 $vtrt_current = get_current_blog_id();51 58 foreach ( $vtrt_sites as $blog_id ) { 52 59 switch_to_blog( (int) $blog_id ); 53 60 vtrt_uninstall_cleanup_single_site(); 61 restore_current_blog(); 54 62 } 55 switch_to_blog( (int) $vtrt_current );56 63 } 57 64 } else {
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)