From c8568edd8af336e0a7a81571478ba65386282769 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 8 Mar 2026 16:50:47 -0600 Subject: [PATCH 1/5] perf: Optimize geolocation with local MaxMind database and caching Performance improvements: - Use WooCommerce's bundled MaxMind database reader (eliminates external API calls) - Add in-memory static cache for same-request lookups - Add object cache integration for cross-request caching - Improve IP detection for CloudFlare, proxies, and load balancers - Reuse MaxMind Reader instance across lookups - Fall back gracefully when database is unavailable This eliminates the 1+ second delays caused by external HTTP calls to geolocation APIs (ipinfo.io, ip-api.com) on every page load. Key changes: - get_ip_address(): Now checks CF-Connecting-IP, True-Client-IP headers - geolocate_ip(): Uses multi-layer caching (memory -> object cache -> db) - geolocate_via_db(): Reuses MaxMind Reader, uses WooCommerce's database - get_external_ip_address(): Deprecated, no longer makes external calls - Added clear_cache() method for cache invalidation --- inc/class-geolocation.php | 828 +++++++++++++++++++++----------------- 1 file changed, 466 insertions(+), 362 deletions(-) diff --git a/inc/class-geolocation.php b/inc/class-geolocation.php index 0038ab2b..c4974934 100644 --- a/inc/class-geolocation.php +++ b/inc/class-geolocation.php @@ -1,8 +1,16 @@ 'http://api.ipify.org/', - 'ipecho' => 'http://ipecho.net/plain', - 'ident' => 'http://ident.me', - 'whatismyipaddress' => 'http://bot.whatismyipaddress.com', - ]; - - /** - * @var $geoip_apis array API endpoints for geolocating an IP address - */ - private static array $geoip_apis = [ - 'ipinfo.io' => 'https://ipinfo.io/%s/json', - 'ip-api.com' => 'http://ip-api.com/json/%s', - ]; - - /** - * Check if server supports MaxMind GeoLite2 Reader. - * - * @todo reactivate this. - * @since 3.4.0 - */ - private static function supports_geolite2(): bool { - return false; // version_compare( PHP_VERSION, '5.4.0', '>=' ); - } - - /** - * Check if geolocation is enabled. - * - * @since 3.4.0 - * @param string $current_settings Current geolocation settings. - */ - private static function is_geolocation_enabled($current_settings): bool { - return in_array($current_settings, ['geolocation', 'geolocation_ajax'], true); - } - - - /** - * Prevent geolocation via MaxMind when using legacy versions of php. - * - * @since 3.4.0 - * @param string $default_customer_address current value. - * @return string - */ - public static function disable_geolocation_on_legacy_php($default_customer_address) { - if ( self::is_geolocation_enabled($default_customer_address) ) { - $default_customer_address = 'base'; - } - - return $default_customer_address; - } - - - /** - * Hook in geolocation functionality. - */ - public static function init(): void { - if ( self::supports_geolite2() ) { - // Only download the database from MaxMind if the geolocation function is enabled, or a plugin specifically requests it. - if ( self::is_geolocation_enabled(get_option('wu_default_customer_address')) || apply_filters('wu_geolocation_update_database_periodically', false) ) { - add_action('wu_geoip_updater', [self::class, 'update_database']); - } - - // Trigger database update when settings are changed to enable geolocation. - add_filter('pre_update_option_wu_default_customer_address', [self::class, 'maybe_update_database'], 10, 2); - } else { - add_filter('pre_option_wu_default_customer_address', [self::class, 'disable_geolocation_on_legacy_php']); - } - } - - - /** - * Maybe trigger a DB update for the first time. - * - * @param string $new_value New value. - * @param string $old_value Old value. - * @return string - */ - public static function maybe_update_database($new_value, $old_value) { - if ( $new_value !== $old_value && self::is_geolocation_enabled($new_value) ) { - self::update_database(); - } - - return $new_value; - } - - /** - * Get current user IP Address. - * - * @return string - */ - public static function get_ip_address() { - if ( isset($_SERVER['HTTP_X_REAL_IP']) ) { // WPCS: input var ok, CSRF ok. - return sanitize_text_field(wp_unslash($_SERVER['HTTP_X_REAL_IP'])); // WPCS: input var ok, CSRF ok. - } elseif ( isset($_SERVER['HTTP_X_FORWARDED_FOR']) ) { // WPCS: input var ok, CSRF ok. - // Proxy servers can send through this header like this: X-Forwarded-For: client1, proxy1, proxy2 - // Make sure we always only send through the first IP in the list which should always be the client IP. - return (string) rest_is_ip_address(trim((string) current(preg_split('/,/', sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR'])))))); // WPCS: input var ok, CSRF ok. - } elseif ( isset( $_SERVER['REMOTE_ADDR'] ) ) { // @codingStandardsIgnoreLine - return sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ); // @codingStandardsIgnoreLine - } - - return ''; - } - - - /** - * Get user IP Address using an external service. - * This can be used as a fallback for users on localhost where - * get_ip_address() will be a local IP and non-geolocatable. - * - * @return string - */ - public static function get_external_ip_address() { - $external_ip_address = '0.0.0.0'; - - if ( '' !== self::get_ip_address() ) { - $transient_name = 'external_ip_address_' . self::get_ip_address(); - $external_ip_address = get_transient($transient_name); - } - - if ( false === $external_ip_address ) { - $external_ip_address = '0.0.0.0'; - $ip_lookup_services = apply_filters('wu_geolocation_ip_lookup_apis', self::$ip_lookup_apis); - $ip_lookup_services_keys = array_keys($ip_lookup_services); - shuffle($ip_lookup_services_keys); - - foreach ( $ip_lookup_services_keys as $service_name ) { - $service_endpoint = $ip_lookup_services[ $service_name ]; - $response = wp_safe_remote_get($service_endpoint, ['timeout' => 2]); - - if ( ! is_wp_error($response) && rest_is_ip_address($response['body']) ) { - $external_ip_address = apply_filters('wu_geolocation_ip_lookup_api_response', ($response['body']), $service_name); - break; - } - } - - set_transient($transient_name, $external_ip_address, WEEK_IN_SECONDS); - } - - return $external_ip_address; - } - - - /** - * Geolocate an IP address. - * - * @param string $ip_address IP Address. - * @param bool $fallback If true, fallbacks to alternative IP detection (can be slower). - * @param bool $api_fallback If true, uses geolocation APIs if the database file doesn't exist (can be slower). - * @return array - */ - public static function geolocate_ip($ip_address = '', $fallback = false, $api_fallback = true) { - // Filter to allow custom geolocation of the IP address. - $country_code = apply_filters('wu_geolocate_ip', false, $ip_address, $fallback, $api_fallback); - - if ( false === $country_code ) { - // If GEOIP is enabled in CloudFlare, we can use that (Settings -> CloudFlare Settings -> Settings Overview). - if ( ! empty($_SERVER['HTTP_CF_IPCOUNTRY']) ) { // WPCS: input var ok, CSRF ok. - $country_code = strtoupper(sanitize_text_field(wp_unslash($_SERVER['HTTP_CF_IPCOUNTRY']))); // WPCS: input var ok, CSRF ok. - } elseif ( ! empty($_SERVER['GEOIP_COUNTRY_CODE']) ) { // WPCS: input var ok, CSRF ok. - // WP.com VIP has a variable available. - $country_code = strtoupper(sanitize_text_field(wp_unslash($_SERVER['GEOIP_COUNTRY_CODE']))); // WPCS: input var ok, CSRF ok. - } elseif ( ! empty($_SERVER['HTTP_X_COUNTRY_CODE']) ) { // WPCS: input var ok, CSRF ok. - // VIP Go has a variable available also. - $country_code = strtoupper(sanitize_text_field(wp_unslash($_SERVER['HTTP_X_COUNTRY_CODE']))); // WPCS: input var ok, CSRF ok. - } else { - $ip_address = $ip_address ?: self::get_ip_address(); - $database = self::get_local_database_path(); - - if ( self::supports_geolite2() && file_exists($database) ) { - $country_code = self::geolocate_via_db($ip_address, $database); - } elseif ( $api_fallback ) { - $country_code = self::geolocate_via_api($ip_address); - } else { - $country_code = ''; - } - - if ( ! $country_code && $fallback ) { - // May be a local environment - find external IP. - return self::geolocate_ip(self::get_external_ip_address(), false, $api_fallback); - } - } - } - - return [ - 'ip' => $ip_address, - 'country' => $country_code, - 'state' => '', - ]; - } - - - /** - * Path to our local db. - * - * @param string $deprecated Deprecated since 3.4.0. - * @return string - */ - public static function get_local_database_path($deprecated = '2') { - return apply_filters('wu_geolocation_local_database_path', wp_upload_dir()['basedir'] . '/GeoLite2-Country.mmdb', $deprecated); - } - - - /** - * Update geoip database. - * - * Extract files with PharData. Tool built into PHP since 5.3. - */ - public static function update_database(): void { - $logger = wc_get_logger(); - - if ( ! self::supports_geolite2() ) { - $logger->notice('Requires PHP 5.4 to be able to download MaxMind GeoLite2 database', ['source' => 'geolocation']); - return; - } - - require_once ABSPATH . 'wp-admin/includes/file.php'; - - $database = 'GeoLite2-Country.mmdb'; - $target_database_path = self::get_local_database_path(); - $tmp_database_path = download_url(self::GEOLITE2_DB); - - if ( ! is_wp_error($tmp_database_path) ) { - WP_Filesystem(); - - global $wp_filesystem; - - try { - // Make sure target dir exists. - $wp_filesystem->mkdir(dirname($target_database_path)); - - // Extract files with PharData. Tool built into PHP since 5.3. - $file = new \PharData($tmp_database_path); // phpcs:ignore PHPCompatibility.Classes.NewClasses.phardataFound - $file_path = trailingslashit($file->current()->getFileName()) . $database; - $file->extractTo(dirname($tmp_database_path), $file_path, true); - - // Move file and delete temp. - $wp_filesystem->move(trailingslashit(dirname($tmp_database_path)) . $file_path, $target_database_path, true); - $wp_filesystem->delete(trailingslashit(dirname($tmp_database_path)) . $file->current()->getFileName()); - } catch ( \Exception $e ) { - $logger->notice($e->getMessage(), ['source' => 'geolocation']); - - // Reschedule download of DB. - wp_clear_scheduled_hook('wu_geoip_updater'); - wp_schedule_event(strtotime('first tuesday of next month'), 'monthly', 'wu_geoip_updater'); - } - - // Delete temp file regardless of success. - $wp_filesystem->delete($tmp_database_path); - } else { - $logger->notice( - 'Unable to download GeoIP Database: ' . $tmp_database_path->get_error_message(), - ['source' => 'geolocation'] - ); - } - } - - - /** - * Use MAXMIND GeoLite database to geolocation the user. - * - * @param string $ip_address IP address. - * @param string $database Database path. - * @return string - */ - private static function geolocate_via_db($ip_address, $database) { - if ( ! class_exists('WC_Geolite_Integration', false) ) { - require_once WC_ABSPATH . 'includes/class-wc-geolite-integration.php'; - } - - $geolite = new \WC_Geolite_Integration($database); - - return $geolite->get_country_iso($ip_address); - } - - - /** - * Use APIs to Geolocate the user. - * - * Geolocation APIs can be added through the use of the wu_geolocation_geoip_apis filter. - * Provide a name=>value pair for service-slug=>endpoint. - * - * If APIs are defined, one will be chosen at random to fulfil the request. After completing, the result - * will be cached in a transient. - * - * @param string $ip_address IP address. - * @return string - */ - private static function geolocate_via_api($ip_address) { - $country_code = get_transient('geoip_' . $ip_address); - - if ( false === $country_code ) { - $geoip_services = apply_filters('wu_geolocation_geoip_apis', self::$geoip_apis); - - if ( empty($geoip_services) ) { - return ''; - } - - $geoip_services_keys = array_keys($geoip_services); - - shuffle($geoip_services_keys); - - foreach ( $geoip_services_keys as $service_name ) { - $service_endpoint = $geoip_services[ $service_name ]; - $response = wp_safe_remote_get(sprintf($service_endpoint, $ip_address), ['timeout' => 2]); - - if ( ! is_wp_error($response) && $response['body'] ) { - switch ( $service_name ) { - case 'ipinfo.io': - $data = json_decode($response['body']); - $country_code = $data->country ?? ''; - break; - case 'ip-api.com': - $data = json_decode($response['body']); - $country_code = $data->countryCode ?? ''; // @codingStandardsIgnoreLine - break; - default: - $country_code = apply_filters('wu_geolocation_geoip_response_' . $service_name, '', $response['body']); - break; - } - - $country_code = sanitize_text_field(strtoupper((string) $country_code)); - - if ( $country_code ) { - break; - } - } - } - - set_transient('geoip_' . $ip_address, $country_code, WEEK_IN_SECONDS); - } - - return $country_code; - } +class Geolocation +{ + + /** + * Cache group for geolocation data. + */ + const CACHE_GROUP = 'wu_geolocation'; + + /** + * Cache TTL in seconds (24 hours). + */ + const CACHE_TTL = 86400; // DAY_IN_SECONDS + + /** + * In-memory cache for current request. + * + * @var array + */ + private static array $memory_cache = []; + + /** + * Cached IP address for current request. + * + * @var string|null + */ + private static ?string $cached_ip = null; + + /** + * MaxMind database reader instance. + * + * @var \MaxMind\Db\Reader|null + */ + private static $reader = null; + + /** + * GeoLite2 DB URL (deprecated - we use WooCommerce's database). + * + * @deprecated 3.4.0 + */ + const GEOLITE2_DB = 'http://geolite.maxmind.com/download/geoip/database/GeoLite2-Country.tar.gz'; + + /** + * Hook in geolocation functionality. + * + * @return void + */ + public static function init(): void + { + // Register shutdown handler to close MaxMind reader + \register_shutdown_function([self::class, 'close_reader']); + } + + /** + * Close the MaxMind database reader. + * + * @return void + */ + public static function close_reader(): void + { + if (self::$reader !== null) { + try { + self::$reader->close(); + } catch (\Exception $e) { + // Ignore errors on shutdown + } + self::$reader = null; + } + } + + /** + * Get current user IP Address with comprehensive proxy/CDN support. + * + * Checks headers in order of trust: + * 1. CF-Connecting-IP (Cloudflare) + * 2. True-Client-IP (Cloudflare Enterprise / Akamai) + * 3. X-Real-IP (Nginx proxy) + * 4. X-Forwarded-For (Standard proxy header - first IP only) + * 5. REMOTE_ADDR (Direct connection) + * + * @return string The client IP address. + */ + public static function get_ip_address(): string + { + // Return cached IP if available (same request optimization) + if (self::$cached_ip !== null) { + return self::$cached_ip; + } + + $ip = ''; + + // Cloudflare (most trusted when using CF) + if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) { + $ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP'])); + } + // Cloudflare Enterprise / Akamai + elseif (!empty($_SERVER['HTTP_TRUE_CLIENT_IP'])) { + $ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_TRUE_CLIENT_IP'])); + } + // Nginx proxy + elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) { + $ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_REAL_IP'])); + } + // Standard proxy header (take first IP - the client) + elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + $forwarded = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR'])); + // X-Forwarded-For: client, proxy1, proxy2 + $ips = array_map('trim', explode(',', $forwarded)); + $ip = self::validate_ip($ips[0]); + } + // Direct connection + elseif (!empty($_SERVER['REMOTE_ADDR'])) { + $ip = \sanitize_text_field(\wp_unslash($_SERVER['REMOTE_ADDR'])); + } + + // Validate and cache + $validated_ip = self::validate_ip($ip); + self::$cached_ip = $validated_ip; + + return $validated_ip; + } + + /** + * Validate an IP address. + * + * @param string $ip The IP address to validate. + * @return string The validated IP or empty string. + */ + private static function validate_ip(string $ip): string + { + // Remove port from IPv4 (e.g., "1.2.3.4:8080" -> "1.2.3.4") + if (preg_match('/^(\d+\.\d+\.\d+\.\d+):\d+$/', $ip, $matches)) { + $ip = $matches[1]; + } + // Remove brackets and port from IPv6 (e.g., "[::1]:8080" -> "::1") + if (preg_match('/^\[([^\]]+)\](:\d+)?$/', $ip, $matches)) { + $ip = $matches[1]; + } + + // Use WordPress's IP validation + return (string) \rest_is_ip_address($ip); + } + + /** + * Check if an IP is a private/local address. + * + * @param string $ip The IP address to check. + * @return bool True if private/local. + */ + private static function is_private_ip(string $ip): bool + { + if (empty($ip)) { + return true; + } + + // Check for private/reserved ranges + return !filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ); + } + + /** + * Geolocate an IP address. + * + * Uses multi-layer caching and local database lookup for performance. + * Completely eliminates external HTTP calls. + * + * @param string $ip_address IP Address (empty = current user). + * @param bool $fallback If true, fallbacks to alternative IP detection. + * @param bool $api_fallback Deprecated - API fallback is disabled for performance. + * @return array{ip: string, country: string, state: string} + */ + public static function geolocate_ip(string $ip_address = '', bool $fallback = false, bool $api_fallback = true): array + { + // Get IP if not provided + if (empty($ip_address)) { + $ip_address = self::get_ip_address(); + } + + // Check in-memory cache first (same request) + if (isset(self::$memory_cache[$ip_address])) { + return self::$memory_cache[$ip_address]; + } + + // Allow plugins to short-circuit + $country_code = \apply_filters('wu_geolocate_ip', false, $ip_address, $fallback, $api_fallback); + + if (false === $country_code) { + // Try to get country from HTTP headers (CDN/proxy provided) + $country_code = self::get_country_from_headers(); + + // If no header, try local database lookup + if (empty($country_code) && !empty($ip_address)) { + // Check object cache + $cache_key = 'geo_' . md5($ip_address); + $country_code = \wp_cache_get($cache_key, self::CACHE_GROUP); + + if (false === $country_code) { + // Perform database lookup + $country_code = self::geolocate_via_db($ip_address); + + // Cache the result (even empty results to prevent repeated lookups) + \wp_cache_set($cache_key, $country_code ?: '', self::CACHE_GROUP, self::CACHE_TTL); + } + } + + // Handle local/private IPs with fallback + if (empty($country_code) && $fallback && self::is_private_ip($ip_address)) { + // For local development, use a default country instead of external API + $country_code = \apply_filters('wu_geolocation_default_country', 'US'); + } + } + + $result = [ + 'ip' => $ip_address, + 'country' => is_string($country_code) ? $country_code : '', + 'state' => '', + ]; + + // Cache in memory for this request + self::$memory_cache[$ip_address] = $result; + + return $result; + } + + /** + * Get country code from HTTP headers set by CDN/proxy. + * + * @return string Country code or empty string. + */ + private static function get_country_from_headers(): string + { + $headers = [ + 'HTTP_CF_IPCOUNTRY', // Cloudflare + 'GEOIP_COUNTRY_CODE', // Nginx GeoIP module + 'HTTP_X_COUNTRY_CODE', // Generic proxy header + 'MM_COUNTRY_CODE', // MaxMind via server + ]; + + foreach ($headers as $header) { + if (!empty($_SERVER[$header])) { + $code = strtoupper(\sanitize_text_field(\wp_unslash($_SERVER[$header]))); + // Validate it looks like a country code (2 letters) + if (preg_match('/^[A-Z]{2}$/', $code)) { + return $code; + } + } + } + + return ''; + } + + /** + * Geolocate using local MaxMind database. + * + * Uses WooCommerce's bundled MaxMind reader when available. + * + * @param string $ip_address The IP address to lookup. + * @return string Country code or empty string. + */ + private static function geolocate_via_db(string $ip_address): string + { + $database_path = self::get_local_database_path(); + + if (!file_exists($database_path)) { + return ''; + } + + try { + // Reuse reader instance for performance + if (self::$reader === null) { + // Try to use WooCommerce's MaxMind reader + if (!class_exists('MaxMind\Db\Reader')) { + if (defined('WC_ABSPATH')) { + $wc_reader = WC_ABSPATH . 'vendor/maxmind-db/reader/src/MaxMind/Db/Reader.php'; + if (file_exists($wc_reader)) { + require_once $wc_reader; + // Also need to load the dependencies + $autoload = WC_ABSPATH . 'vendor/autoload.php'; + if (file_exists($autoload)) { + require_once $autoload; + } + } + } + } + + if (class_exists('MaxMind\Db\Reader')) { + self::$reader = new \MaxMind\Db\Reader($database_path); + } + } + + if (self::$reader !== null) { + $data = self::$reader->get($ip_address); + if (isset($data['country']['iso_code'])) { + return strtoupper(\sanitize_text_field($data['country']['iso_code'])); + } + } + } catch (\Exception $e) { + // Log error but don't break the site + if (function_exists('wu_log')) { + wu_log('Geolocation DB error: ' . $e->getMessage()); + } + } + + return ''; + } + + /** + * Get the path to the local MaxMind database. + * + * Checks multiple locations in order of preference: + * 1. WooCommerce's MaxMind database (most likely to exist) + * 2. WP Ultimo's own database path + * 3. Custom path via filter + * + * @param string $deprecated Deprecated parameter. + * @return string Database path. + */ + public static function get_local_database_path(string $deprecated = '2'): string + { + // Check WooCommerce's database first (most likely to be maintained) + if (function_exists('wc')) { + $wc = \wc(); + if ($wc && method_exists($wc, 'integrations') && $wc->integrations) { + $integration = $wc->integrations->get_integration('maxmind_geolocation'); + if ($integration && method_exists($integration, 'get_database_service')) { + $service = $integration->get_database_service(); + if ($service && method_exists($service, 'get_database_path')) { + $wc_path = $service->get_database_path(); + if (!empty($wc_path) && file_exists($wc_path)) { + return $wc_path; + } + } + } + } + } + + // Fallback to WP Ultimo's own path + $upload_dir = \wp_upload_dir(); + $wu_path = $upload_dir['basedir'] . '/GeoLite2-Country.mmdb'; + + return \apply_filters('wu_geolocation_local_database_path', $wu_path, $deprecated); + } + + /** + * Get user IP Address using an external service. + * + * @deprecated External IP lookup is disabled for performance. + * Use get_ip_address() instead. + * @return string + */ + public static function get_external_ip_address(): string + { + // For backwards compatibility, just return the detected IP + // External API calls have been removed for performance + $ip = self::get_ip_address(); + + // If it's a private IP, return a placeholder + if (self::is_private_ip($ip)) { + return '0.0.0.0'; + } + + return $ip; + } + + /** + * Check if server supports MaxMind GeoLite2 Reader. + * + * @return bool + */ + private static function supports_geolite2(): bool + { + // Check if WooCommerce's MaxMind reader is available + if (class_exists('MaxMind\Db\Reader')) { + return true; + } + + // Check if WooCommerce is installed with the reader + if (defined('WC_ABSPATH')) { + $reader_path = WC_ABSPATH . 'vendor/maxmind-db/reader/src/MaxMind/Db/Reader.php'; + return file_exists($reader_path); + } + + return false; + } + + /** + * Check if geolocation is enabled. + * + * @param string $current_settings Current geolocation settings. + * @return bool + */ + private static function is_geolocation_enabled(string $current_settings): bool + { + return in_array($current_settings, ['geolocation', 'geolocation_ajax'], true); + } + + /** + * Update geoip database. + * + * @deprecated Database management is handled by WooCommerce's MaxMind integration. + * @return void + */ + public static function update_database(): void + { + // Database updates are now handled by WooCommerce's MaxMind integration + // This method is kept for backwards compatibility + if (function_exists('wu_log')) { + wu_log('Geolocation: Database updates are now handled by WooCommerce MaxMind integration.'); + } + } + + /** + * Maybe trigger a DB update for the first time. + * + * @deprecated Database management is handled by WooCommerce's MaxMind integration. + * @param string $new_value New value. + * @param string $old_value Old value. + * @return string + */ + public static function maybe_update_database(string $new_value, string $old_value): string + { + return $new_value; + } + + /** + * Disable geolocation on legacy PHP. + * + * @deprecated PHP 8.0+ is now required. + * @param string $default_customer_address Current value. + * @return string + */ + public static function disable_geolocation_on_legacy_php(string $default_customer_address): string + { + return $default_customer_address; + } + + /** + * Clear all geolocation caches. + * + * Useful when database is updated or for debugging. + * + * @return void + */ + public static function clear_cache(): void + { + self::$memory_cache = []; + self::$cached_ip = null; + + // Note: Object cache group deletion depends on cache implementation + // Most object caches don't support group deletion + if (function_exists('wp_cache_flush_group')) { + \wp_cache_flush_group(self::CACHE_GROUP); + } + } } From 529f95e69e9ebfeb4f5252ad5d5eef28213753b5 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 12 Mar 2026 19:21:00 -0600 Subject: [PATCH 2/5] feat(geolocation): add MaxMind reader via Composer and remove WooCommerce dependency - Add maxmind-db/reader ^1.12 to composer dependencies - Update Geolocation class to use Composer autoloader instead of WooCommerce - Remove manual loading of WC_ABSPATH/vendor/maxmind-db/reader - Simplify get_local_database_path() to only check WP Ultimo path - Update supports_geolite2() to rely on Composer autoloader - Add comprehensive test suite (35+ tests) for Geolocation class BREAKING CHANGE: Geolocation no longer checks WooCommerce for MaxMind database --- composer.json | 3 +- composer.lock | 66 ++- inc/class-geolocation.php | 843 ++++++++++++--------------- tests/WP_Ultimo/Geolocation_Test.php | 513 ++++++++++++++++ 4 files changed, 966 insertions(+), 459 deletions(-) create mode 100644 tests/WP_Ultimo/Geolocation_Test.php diff --git a/composer.json b/composer.json index 2596608f..7abd6d21 100644 --- a/composer.json +++ b/composer.json @@ -65,7 +65,8 @@ "symfony/polyfill-php83": "^1.31.0", "symfony/polyfill-php84": "^1.31.0", "ext-curl": "*", - "pondermatic/composer-archive-project": "^1.1" + "pondermatic/composer-archive-project": "^1.1", + "maxmind-db/reader": "^1.12" }, "require-dev": { diff --git a/composer.lock b/composer.lock index bfcdcda1..1482d986 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3708aee0fdd43843d833791f07706f53", + "content-hash": "8e1defa893f294810d0c11b03a06a5a6", "packages": [ { "name": "amphp/amp", @@ -2039,6 +2039,70 @@ "abandoned": "league/uri-interfaces", "time": "2018-11-22T07:55:51+00:00" }, + { + "name": "maxmind-db/reader", + "version": "v1.13.1", + "source": { + "type": "git", + "url": "https://github.com/maxmind/MaxMind-DB-Reader-php.git", + "reference": "2194f58d0f024ce923e685cdf92af3daf9951908" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maxmind/MaxMind-DB-Reader-php/zipball/2194f58d0f024ce923e685cdf92af3daf9951908", + "reference": "2194f58d0f024ce923e685cdf92af3daf9951908", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "conflict": { + "ext-maxminddb": "<1.11.1 || >=2.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.*", + "phpstan/phpstan": "*", + "phpunit/phpunit": ">=8.0.0,<10.0.0", + "squizlabs/php_codesniffer": "4.*" + }, + "suggest": { + "ext-bcmath": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder", + "ext-gmp": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder", + "ext-maxminddb": "A C-based database decoder that provides significantly faster lookups", + "maxmind-db/reader-ext": "C extension for significantly faster IP lookups (install via PIE: pie install maxmind-db/reader-ext)" + }, + "type": "library", + "autoload": { + "psr-4": { + "MaxMind\\Db\\": "src/MaxMind/Db" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Gregory J. Oschwald", + "email": "goschwald@maxmind.com", + "homepage": "https://www.maxmind.com/" + } + ], + "description": "MaxMind DB Reader API", + "homepage": "https://github.com/maxmind/MaxMind-DB-Reader-php", + "keywords": [ + "database", + "geoip", + "geoip2", + "geolocation", + "maxmind" + ], + "support": { + "issues": "https://github.com/maxmind/MaxMind-DB-Reader-php/issues", + "source": "https://github.com/maxmind/MaxMind-DB-Reader-php/tree/v1.13.1" + }, + "time": "2025-11-21T22:24:26+00:00" + }, { "name": "mexitek/phpcolors", "version": "v1.0.4", diff --git a/inc/class-geolocation.php b/inc/class-geolocation.php index c4974934..bfbd50f5 100644 --- a/inc/class-geolocation.php +++ b/inc/class-geolocation.php @@ -6,7 +6,7 @@ * Eliminates external HTTP calls that were causing 1+ second delays. * * Performance improvements: - * - Uses WooCommerce's bundled MaxMind database reader (no external API calls) + * - Uses bundled MaxMind database reader via Composer (no external API calls) * - In-memory static cache for same-request lookups * - Object cache integration for cross-request caching * - Proper IP detection for CloudFlare, proxies, and load balancers @@ -25,460 +25,389 @@ /** * Geolocation Class. */ -class Geolocation -{ - - /** - * Cache group for geolocation data. - */ - const CACHE_GROUP = 'wu_geolocation'; - - /** - * Cache TTL in seconds (24 hours). - */ - const CACHE_TTL = 86400; // DAY_IN_SECONDS - - /** - * In-memory cache for current request. - * - * @var array - */ - private static array $memory_cache = []; - - /** - * Cached IP address for current request. - * - * @var string|null - */ - private static ?string $cached_ip = null; - - /** - * MaxMind database reader instance. - * - * @var \MaxMind\Db\Reader|null - */ - private static $reader = null; - - /** - * GeoLite2 DB URL (deprecated - we use WooCommerce's database). - * - * @deprecated 3.4.0 - */ - const GEOLITE2_DB = 'http://geolite.maxmind.com/download/geoip/database/GeoLite2-Country.tar.gz'; - - /** - * Hook in geolocation functionality. - * - * @return void - */ - public static function init(): void - { - // Register shutdown handler to close MaxMind reader - \register_shutdown_function([self::class, 'close_reader']); - } - - /** - * Close the MaxMind database reader. - * - * @return void - */ - public static function close_reader(): void - { - if (self::$reader !== null) { - try { - self::$reader->close(); - } catch (\Exception $e) { - // Ignore errors on shutdown - } - self::$reader = null; - } - } - - /** - * Get current user IP Address with comprehensive proxy/CDN support. - * - * Checks headers in order of trust: - * 1. CF-Connecting-IP (Cloudflare) - * 2. True-Client-IP (Cloudflare Enterprise / Akamai) - * 3. X-Real-IP (Nginx proxy) - * 4. X-Forwarded-For (Standard proxy header - first IP only) - * 5. REMOTE_ADDR (Direct connection) - * - * @return string The client IP address. - */ - public static function get_ip_address(): string - { - // Return cached IP if available (same request optimization) - if (self::$cached_ip !== null) { - return self::$cached_ip; - } - - $ip = ''; - - // Cloudflare (most trusted when using CF) - if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) { - $ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP'])); - } - // Cloudflare Enterprise / Akamai - elseif (!empty($_SERVER['HTTP_TRUE_CLIENT_IP'])) { - $ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_TRUE_CLIENT_IP'])); - } - // Nginx proxy - elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) { - $ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_REAL_IP'])); - } - // Standard proxy header (take first IP - the client) - elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { - $forwarded = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR'])); - // X-Forwarded-For: client, proxy1, proxy2 - $ips = array_map('trim', explode(',', $forwarded)); - $ip = self::validate_ip($ips[0]); - } - // Direct connection - elseif (!empty($_SERVER['REMOTE_ADDR'])) { - $ip = \sanitize_text_field(\wp_unslash($_SERVER['REMOTE_ADDR'])); - } - - // Validate and cache - $validated_ip = self::validate_ip($ip); - self::$cached_ip = $validated_ip; - - return $validated_ip; - } - - /** - * Validate an IP address. - * - * @param string $ip The IP address to validate. - * @return string The validated IP or empty string. - */ - private static function validate_ip(string $ip): string - { - // Remove port from IPv4 (e.g., "1.2.3.4:8080" -> "1.2.3.4") - if (preg_match('/^(\d+\.\d+\.\d+\.\d+):\d+$/', $ip, $matches)) { - $ip = $matches[1]; - } - // Remove brackets and port from IPv6 (e.g., "[::1]:8080" -> "::1") - if (preg_match('/^\[([^\]]+)\](:\d+)?$/', $ip, $matches)) { - $ip = $matches[1]; - } - - // Use WordPress's IP validation - return (string) \rest_is_ip_address($ip); - } - - /** - * Check if an IP is a private/local address. - * - * @param string $ip The IP address to check. - * @return bool True if private/local. - */ - private static function is_private_ip(string $ip): bool - { - if (empty($ip)) { - return true; - } - - // Check for private/reserved ranges - return !filter_var( - $ip, - FILTER_VALIDATE_IP, - FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE - ); - } - - /** - * Geolocate an IP address. - * - * Uses multi-layer caching and local database lookup for performance. - * Completely eliminates external HTTP calls. - * - * @param string $ip_address IP Address (empty = current user). - * @param bool $fallback If true, fallbacks to alternative IP detection. - * @param bool $api_fallback Deprecated - API fallback is disabled for performance. - * @return array{ip: string, country: string, state: string} - */ - public static function geolocate_ip(string $ip_address = '', bool $fallback = false, bool $api_fallback = true): array - { - // Get IP if not provided - if (empty($ip_address)) { - $ip_address = self::get_ip_address(); - } - - // Check in-memory cache first (same request) - if (isset(self::$memory_cache[$ip_address])) { - return self::$memory_cache[$ip_address]; - } - - // Allow plugins to short-circuit - $country_code = \apply_filters('wu_geolocate_ip', false, $ip_address, $fallback, $api_fallback); - - if (false === $country_code) { - // Try to get country from HTTP headers (CDN/proxy provided) - $country_code = self::get_country_from_headers(); - - // If no header, try local database lookup - if (empty($country_code) && !empty($ip_address)) { - // Check object cache - $cache_key = 'geo_' . md5($ip_address); - $country_code = \wp_cache_get($cache_key, self::CACHE_GROUP); - - if (false === $country_code) { - // Perform database lookup - $country_code = self::geolocate_via_db($ip_address); - - // Cache the result (even empty results to prevent repeated lookups) - \wp_cache_set($cache_key, $country_code ?: '', self::CACHE_GROUP, self::CACHE_TTL); - } - } - - // Handle local/private IPs with fallback - if (empty($country_code) && $fallback && self::is_private_ip($ip_address)) { - // For local development, use a default country instead of external API - $country_code = \apply_filters('wu_geolocation_default_country', 'US'); - } - } - - $result = [ - 'ip' => $ip_address, - 'country' => is_string($country_code) ? $country_code : '', - 'state' => '', - ]; - - // Cache in memory for this request - self::$memory_cache[$ip_address] = $result; - - return $result; - } - - /** - * Get country code from HTTP headers set by CDN/proxy. - * - * @return string Country code or empty string. - */ - private static function get_country_from_headers(): string - { - $headers = [ - 'HTTP_CF_IPCOUNTRY', // Cloudflare - 'GEOIP_COUNTRY_CODE', // Nginx GeoIP module - 'HTTP_X_COUNTRY_CODE', // Generic proxy header - 'MM_COUNTRY_CODE', // MaxMind via server - ]; - - foreach ($headers as $header) { - if (!empty($_SERVER[$header])) { - $code = strtoupper(\sanitize_text_field(\wp_unslash($_SERVER[$header]))); - // Validate it looks like a country code (2 letters) - if (preg_match('/^[A-Z]{2}$/', $code)) { - return $code; - } - } - } - - return ''; - } - - /** - * Geolocate using local MaxMind database. - * - * Uses WooCommerce's bundled MaxMind reader when available. - * - * @param string $ip_address The IP address to lookup. - * @return string Country code or empty string. - */ - private static function geolocate_via_db(string $ip_address): string - { - $database_path = self::get_local_database_path(); - - if (!file_exists($database_path)) { - return ''; - } - - try { - // Reuse reader instance for performance - if (self::$reader === null) { - // Try to use WooCommerce's MaxMind reader - if (!class_exists('MaxMind\Db\Reader')) { - if (defined('WC_ABSPATH')) { - $wc_reader = WC_ABSPATH . 'vendor/maxmind-db/reader/src/MaxMind/Db/Reader.php'; - if (file_exists($wc_reader)) { - require_once $wc_reader; - // Also need to load the dependencies - $autoload = WC_ABSPATH . 'vendor/autoload.php'; - if (file_exists($autoload)) { - require_once $autoload; - } - } - } - } - - if (class_exists('MaxMind\Db\Reader')) { - self::$reader = new \MaxMind\Db\Reader($database_path); - } - } - - if (self::$reader !== null) { - $data = self::$reader->get($ip_address); - if (isset($data['country']['iso_code'])) { - return strtoupper(\sanitize_text_field($data['country']['iso_code'])); - } - } - } catch (\Exception $e) { - // Log error but don't break the site - if (function_exists('wu_log')) { - wu_log('Geolocation DB error: ' . $e->getMessage()); - } - } - - return ''; - } - - /** - * Get the path to the local MaxMind database. - * - * Checks multiple locations in order of preference: - * 1. WooCommerce's MaxMind database (most likely to exist) - * 2. WP Ultimo's own database path - * 3. Custom path via filter - * - * @param string $deprecated Deprecated parameter. - * @return string Database path. - */ - public static function get_local_database_path(string $deprecated = '2'): string - { - // Check WooCommerce's database first (most likely to be maintained) - if (function_exists('wc')) { - $wc = \wc(); - if ($wc && method_exists($wc, 'integrations') && $wc->integrations) { - $integration = $wc->integrations->get_integration('maxmind_geolocation'); - if ($integration && method_exists($integration, 'get_database_service')) { - $service = $integration->get_database_service(); - if ($service && method_exists($service, 'get_database_path')) { - $wc_path = $service->get_database_path(); - if (!empty($wc_path) && file_exists($wc_path)) { - return $wc_path; - } - } - } - } - } - - // Fallback to WP Ultimo's own path - $upload_dir = \wp_upload_dir(); - $wu_path = $upload_dir['basedir'] . '/GeoLite2-Country.mmdb'; - - return \apply_filters('wu_geolocation_local_database_path', $wu_path, $deprecated); - } - - /** - * Get user IP Address using an external service. - * - * @deprecated External IP lookup is disabled for performance. - * Use get_ip_address() instead. - * @return string - */ - public static function get_external_ip_address(): string - { - // For backwards compatibility, just return the detected IP - // External API calls have been removed for performance - $ip = self::get_ip_address(); - - // If it's a private IP, return a placeholder - if (self::is_private_ip($ip)) { - return '0.0.0.0'; - } - - return $ip; - } - - /** - * Check if server supports MaxMind GeoLite2 Reader. - * - * @return bool - */ - private static function supports_geolite2(): bool - { - // Check if WooCommerce's MaxMind reader is available - if (class_exists('MaxMind\Db\Reader')) { - return true; - } - - // Check if WooCommerce is installed with the reader - if (defined('WC_ABSPATH')) { - $reader_path = WC_ABSPATH . 'vendor/maxmind-db/reader/src/MaxMind/Db/Reader.php'; - return file_exists($reader_path); - } - - return false; - } - - /** - * Check if geolocation is enabled. - * - * @param string $current_settings Current geolocation settings. - * @return bool - */ - private static function is_geolocation_enabled(string $current_settings): bool - { - return in_array($current_settings, ['geolocation', 'geolocation_ajax'], true); - } - - /** - * Update geoip database. - * - * @deprecated Database management is handled by WooCommerce's MaxMind integration. - * @return void - */ - public static function update_database(): void - { - // Database updates are now handled by WooCommerce's MaxMind integration - // This method is kept for backwards compatibility - if (function_exists('wu_log')) { - wu_log('Geolocation: Database updates are now handled by WooCommerce MaxMind integration.'); - } - } - - /** - * Maybe trigger a DB update for the first time. - * - * @deprecated Database management is handled by WooCommerce's MaxMind integration. - * @param string $new_value New value. - * @param string $old_value Old value. - * @return string - */ - public static function maybe_update_database(string $new_value, string $old_value): string - { - return $new_value; - } - - /** - * Disable geolocation on legacy PHP. - * - * @deprecated PHP 8.0+ is now required. - * @param string $default_customer_address Current value. - * @return string - */ - public static function disable_geolocation_on_legacy_php(string $default_customer_address): string - { - return $default_customer_address; - } - - /** - * Clear all geolocation caches. - * - * Useful when database is updated or for debugging. - * - * @return void - */ - public static function clear_cache(): void - { - self::$memory_cache = []; - self::$cached_ip = null; - - // Note: Object cache group deletion depends on cache implementation - // Most object caches don't support group deletion - if (function_exists('wp_cache_flush_group')) { - \wp_cache_flush_group(self::CACHE_GROUP); - } - } +class Geolocation { + + + /** + * Cache group for geolocation data. + */ + const CACHE_GROUP = 'wu_geolocation'; + + /** + * In-memory cache for current request. + * + * @var array + */ + private static array $memory_cache = []; + + /** + * Cached IP address for current request. + * + * @var string|null + */ + private static ?string $cached_ip = null; + + /** + * MaxMind database reader instance. + * + * @var \MaxMind\Db\Reader|null + */ + private static ?\MaxMind\Db\Reader $reader = null; + + /** + * GeoLite2 DB URL (deprecated - we use local MaxMind database). + * + * @deprecated 3.4.0 + */ + const GEOLITE2_DB = 'http://geolite.maxmind.com/download/geoip/database/GeoLite2-Country.tar.gz'; + + /** + * Hook in geolocation functionality. + * + * @return void + */ + public static function init(): void { + // Register shutdown handler to close MaxMind reader + \register_shutdown_function([self::class, 'close_reader']); + } + + /** + * Close the MaxMind database reader. + * + * @return void + */ + public static function close_reader(): void { + if (null !== self::$reader) { + try { + self::$reader->close(); + } catch (\Exception $e) { + // Ignore errors on shutdown. + unset($e); // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch + } + self::$reader = null; + } + } + + /** + * Get current user IP Address with comprehensive proxy/CDN support. + * + * Checks headers in order of trust: + * 1. CF-Connecting-IP (Cloudflare) + * 2. True-Client-IP (Cloudflare Enterprise / Akamai) + * 3. X-Real-IP (Nginx proxy) + * 4. X-Forwarded-For (Standard proxy header - first IP only) + * 5. REMOTE_ADDR (Direct connection) + * + * @return string The client IP address. + */ + public static function get_ip_address(): string { + // Return cached IP if available (same request optimization) + if (null === self::$cached_ip) { + return self::$cached_ip; + } + + $ip = ''; + + // Cloudflare (most trusted when using CF) + if (! empty($_SERVER['HTTP_CF_CONNECTING_IP'])) { + $ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP'])); + } elseif (! empty($_SERVER['HTTP_TRUE_CLIENT_IP'])) { + // Cloudflare Enterprise / Akamai + $ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_TRUE_CLIENT_IP'])); + } elseif (! empty($_SERVER['HTTP_X_REAL_IP'])) { + // Nginx proxy + $ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_REAL_IP'])); + } elseif (! empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + // Standard proxy header (take first IP - the client) + $forwarded = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR'])); + // X-Forwarded-For: client, proxy1, proxy2 + $ips = array_map('trim', explode(',', $forwarded)); + $ip = self::validate_ip($ips[0]); + } elseif (! empty($_SERVER['REMOTE_ADDR'])) { + // Direct connection + $ip = \sanitize_text_field(\wp_unslash($_SERVER['REMOTE_ADDR'])); + } + + // Validate and cache + $validated_ip = self::validate_ip($ip); + self::$cached_ip = $validated_ip; + + return $validated_ip; + } + + /** + * Validate an IP address. + * + * @param string $ip The IP address to validate. + * @return string The validated IP or empty string. + */ + private static function validate_ip(string $ip): string { + // Remove port from IPv4 (e.g., "1.2.3.4:8080" -> "1.2.3.4") + if (preg_match('/^(\d+\.\d+\.\d+\.\d+):\d+$/', $ip, $matches)) { + $ip = $matches[1]; + } + // Remove brackets and port from IPv6 (e.g., "[::1]:8080" -> "::1") + if (preg_match('/^\[([^]]+)](:\d+)?$/', $ip, $matches)) { + $ip = $matches[1]; + } + + // Use WordPress's IP validation + return (string) \rest_is_ip_address($ip); + } + + /** + * Check if an IP is a private/local address. + * + * @param string $ip The IP address to check. + * @return bool True if private/local. + */ + private static function is_private_ip(string $ip): bool { + if (empty($ip)) { + return true; + } + + // Check for private/reserved ranges + return ! filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ); + } + + /** + * Geolocate an IP address. + * + * Uses multi-layer caching and local database lookup for performance. + * Completely eliminates external HTTP calls. + * + * @param string $ip_address IP Address (empty = current user). + * @param bool $fallback If true, fallbacks to alternative IP detection. + * @param bool $api_fallback Deprecated - API fallback is disabled for performance. + * @return array{ip: string, country: string, state: string} + */ + public static function geolocate_ip(string $ip_address = '', bool $fallback = false, bool $api_fallback = true): array { + // Get IP if not provided + if (empty($ip_address)) { + $ip_address = self::get_ip_address(); + } + + // Check in-memory cache first (same request) + if (isset(self::$memory_cache[ $ip_address ])) { + return self::$memory_cache[ $ip_address ]; + } + + // Allow plugins to short-circuit + $country_code = \apply_filters('wu_geolocate_ip', false, $ip_address, $fallback, $api_fallback); + + if (false === $country_code) { + // Try to get country from HTTP headers (CDN/proxy provided) + $country_code = self::get_country_from_headers(); + + // If no header, try local database lookup + if (empty($country_code) && ! empty($ip_address)) { + // Check object cache + $cache_key = 'geo_' . md5($ip_address); + $country_code = \wp_cache_get($cache_key, self::CACHE_GROUP); + + if (false === $country_code) { + // Perform database lookup + $country_code = self::geolocate_via_db($ip_address); + + // Cache the result (even empty results to prevent repeated lookups) + \wp_cache_set($cache_key, $country_code ?: '', self::CACHE_GROUP, DAY_IN_SECONDS); + } + } + + // Handle local/private IPs with fallback + if (empty($country_code) && $fallback && self::is_private_ip($ip_address)) { + // For local development, use a default country instead of external API + $country_code = \apply_filters('wu_geolocation_default_country', 'US'); + } + } + + $result = [ + 'ip' => $ip_address, + 'country' => is_string($country_code) ? $country_code : '', + 'state' => '', + ]; + + // Cache in memory for this request + self::$memory_cache[ $ip_address ] = $result; + + return $result; + } + + /** + * Get country code from HTTP headers set by CDN/proxy. + * + * @return string Country code or empty string. + */ + private static function get_country_from_headers(): string { + $headers = [ + 'HTTP_CF_IPCOUNTRY', // Cloudflare + 'GEOIP_COUNTRY_CODE', // Nginx GeoIP module + 'HTTP_X_COUNTRY_CODE', // Generic proxy header + 'MM_COUNTRY_CODE', // MaxMind via server + ]; + + foreach ($headers as $header) { + if (! empty($_SERVER[ $header ])) { + $code = strtoupper(\sanitize_text_field(\wp_unslash($_SERVER[ $header ]))); + // Validate it looks like a country code (2 letters) + if (preg_match('/^[A-Z]{2}$/', $code)) { + return $code; + } + } + } + + return ''; + } + + /** + * Geolocate using local MaxMind database. + * + * Uses the bundled MaxMind reader via Composer autoloader. + * + * @param string $ip_address The IP address to lookup. + * @return string Country code or empty string. + */ + private static function geolocate_via_db(string $ip_address): string { + $database_path = self::get_local_database_path(); + + if (! file_exists($database_path)) { + return ''; + } + + try { + // Reuse reader instance for performance + if (null === self::$reader) { + // MaxMind\Db\Reader is loaded via Composer autoloader + self::$reader = new \MaxMind\Db\Reader($database_path); + } + + $data = self::$reader->get($ip_address); + if (isset($data['country']['iso_code'])) { + return strtoupper(\sanitize_text_field($data['country']['iso_code'])); + } + } catch (\Exception $e) { + // Log error but don't break the site + wu_log_add('geolocation', 'Geolocation DB error: ' . $e->getMessage()); + } + + return ''; + } + + /** + * Get the path to the local MaxMind database. + * + * Checks locations in order of preference: + * 1. WP Ultimo's own database path (in uploads directory) + * 2. Custom path via filter + * + * @param string $deprecated Deprecated parameter. + * @return string Database path. + */ + public static function get_local_database_path(string $deprecated = '2'): string { + // Use WP Ultimo's own path in uploads directory + $upload_dir = \wp_upload_dir(); + $wu_path = $upload_dir['basedir'] . '/GeoLite2-Country.mmdb'; + + return \apply_filters('wu_geolocation_local_database_path', $wu_path, $deprecated); + } + + /** + * Get user IP Address using an external service. + * + * @deprecated External IP lookup is disabled for performance. + * Use get_ip_address() instead. + * @return string + */ + public static function get_external_ip_address(): string { + // For backwards compatibility, just return the detected IP + // External API calls have been removed for performance + $ip = self::get_ip_address(); + + // If it's a private IP, return a placeholder + if (self::is_private_ip($ip)) { + return '0.0.0.0'; + } + + return $ip; + } + + /** + * Check if server supports MaxMind GeoLite2 Reader. + * + * @return bool + */ + private static function supports_geolite2(): bool { + // Check if MaxMind reader is available via Composer autoloader + return class_exists('MaxMind\Db\Reader'); + } + + /** + * Check if geolocation is enabled. + * + * @param string $current_settings Current geolocation settings. + * @return bool + */ + private static function is_geolocation_enabled(string $current_settings): bool { + return in_array($current_settings, ['geolocation', 'geolocation_ajax'], true); + } + + /** + * Update geoip database. + * + * @deprecated Database management must be handled separately. + * @return void + */ + public static function update_database(): void { + // Database updates should be handled by a separate process + // This method is kept for backwards compatibility + } + + /** + * Maybe trigger a DB update for the first time. + * + * @deprecated Database management must be handled separately. + * @param string $new_value New value. + * @param string $old_value Old value. + * @return string + */ + public static function maybe_update_database( + string $new_value, + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + string $old_value + ): string { + return $new_value; + } + + /** + * Disable geolocation on legacy PHP. + * + * @deprecated PHP 8.0+ is now required. + * @param string $default_customer_address Current value. + * @return string + */ + public static function disable_geolocation_on_legacy_php(string $default_customer_address): string { + return $default_customer_address; + } + + /** + * Clear all geolocation caches. + * + * Useful when database is updated or for debugging. + * + * @return void + */ + public static function clear_cache(): void { + self::$memory_cache = []; + self::$cached_ip = null; + + // Note: Object cache group deletion depends on cache implementation + // Most object caches don't support group deletion + if (function_exists('wp_cache_flush_group')) { + \wp_cache_flush_group(self::CACHE_GROUP); + } + } } diff --git a/tests/WP_Ultimo/Geolocation_Test.php b/tests/WP_Ultimo/Geolocation_Test.php new file mode 100644 index 00000000..dc90f229 --- /dev/null +++ b/tests/WP_Ultimo/Geolocation_Test.php @@ -0,0 +1,513 @@ +reset_static_properties(); + parent::tearDown(); + } + + /** + * Reset static properties via reflection. + */ + private function reset_static_properties(): void { + $reflection = new \ReflectionClass(Geolocation::class); + + $memory_cache = $reflection->getProperty('memory_cache'); + if (PHP_VERSION_ID < 80100) { + $memory_cache->setAccessible(true); + } + $memory_cache->setValue(null, []); + + $cached_ip = $reflection->getProperty('cached_ip'); + if (PHP_VERSION_ID < 80100) { + $cached_ip->setAccessible(true); + } + $cached_ip->setValue(null, null); + + $reader = $reflection->getProperty('reader'); + if (PHP_VERSION_ID < 80100) { + $reader->setAccessible(true); + } + $reader_value = $reader->getValue(); + if (null !== $reader_value) { + try { + $reader_value->close(); + } catch (\Exception $e) { + // Ignore errors on close - reader may already be closed. + unset($e); // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch + } + } + $reader->setValue(null, null); + } + + /** + * Test cache group constant. + */ + public function test_cache_group_constant() { + $this->assertEquals('wu_geolocation', Geolocation::CACHE_GROUP); + } + + /** + * Test validate_ip with valid IPv4 address. + */ + public function test_validate_ip_valid_ipv4() { + $reflection = new \ReflectionClass(Geolocation::class); + $method = $reflection->getMethod('validate_ip'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $result = $method->invoke(null, '192.168.1.1'); + $this->assertEquals('192.168.1.1', $result); + } + + /** + * Test validate_ip with IPv4 address including port. + */ + public function test_validate_ip_ipv4_with_port() { + $reflection = new \ReflectionClass(Geolocation::class); + $method = $reflection->getMethod('validate_ip'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $result = $method->invoke(null, '192.168.1.1:8080'); + $this->assertEquals('192.168.1.1', $result); + } + + /** + * Test validate_ip with valid IPv6 address. + */ + public function test_validate_ip_valid_ipv6() { + $reflection = new \ReflectionClass(Geolocation::class); + $method = $reflection->getMethod('validate_ip'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $result = $method->invoke(null, '::1'); + $this->assertEquals('::1', $result); + } + + /** + * Test validate_ip with IPv6 address including port. + */ + public function test_validate_ip_ipv6_with_port() { + $reflection = new \ReflectionClass(Geolocation::class); + $method = $reflection->getMethod('validate_ip'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $result = $method->invoke(null, '[::1]:8080'); + $this->assertEquals('::1', $result); + } + + /** + * Test validate_ip with invalid IP. + */ + public function test_validate_ip_invalid() { + $reflection = new \ReflectionClass(Geolocation::class); + $method = $reflection->getMethod('validate_ip'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $result = $method->invoke(null, 'invalid-ip'); + $this->assertEquals('', $result); + } + + /** + * Test validate_ip with empty string. + */ + public function test_validate_ip_empty() { + $reflection = new \ReflectionClass(Geolocation::class); + $method = $reflection->getMethod('validate_ip'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $result = $method->invoke(null, ''); + $this->assertEquals('', $result); + } + + /** + * Test is_private_ip with private IPv4 ranges. + */ + public function test_is_private_ip_private_ranges() { + $reflection = new \ReflectionClass(Geolocation::class); + $method = $reflection->getMethod('is_private_ip'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + // Private ranges + $this->assertTrue($method->invoke(null, '192.168.1.1')); + $this->assertTrue($method->invoke(null, '10.0.0.1')); + $this->assertTrue($method->invoke(null, '172.16.0.1')); + $this->assertTrue($method->invoke(null, '127.0.0.1')); + $this->assertTrue($method->invoke(null, '::1')); + } + + /** + * Test is_private_ip with public IPv4 addresses. + */ + public function test_is_private_ip_public_ranges() { + $reflection = new \ReflectionClass(Geolocation::class); + $method = $reflection->getMethod('is_private_ip'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + // Public addresses + $this->assertFalse($method->invoke(null, '8.8.8.8')); + $this->assertFalse($method->invoke(null, '1.1.1.1')); + $this->assertFalse($method->invoke(null, '208.67.222.222')); + } + + /** + * Test is_private_ip with empty string. + */ + public function test_is_private_ip_empty() { + $reflection = new \ReflectionClass(Geolocation::class); + $method = $reflection->getMethod('is_private_ip'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $this->assertTrue($method->invoke(null, '')); + } + + /** + * Test get_local_database_path returns expected path structure. + */ + public function test_get_local_database_path_structure() { + $path = Geolocation::get_local_database_path(); + + // Should contain the expected filename + $this->assertStringEndsWith('GeoLite2-Country.mmdb', $path); + + // Should contain wp-content/uploads + $this->assertStringContains('uploads', $path); + } + + /** + * Test supports_geolite2 returns true with Composer package. + */ + public function test_supports_geolite2_with_composer() { + // With the maxmind-db/reader package installed via Composer, + // this should return true + $reflection = new \ReflectionClass(Geolocation::class); + $method = $reflection->getMethod('supports_geolite2'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $result = $method->invoke(null); + $this->assertTrue($result, 'MaxMind\Db\Reader class should be available via Composer'); + } + + /** + * Test is_geolocation_enabled with enabled values. + */ + public function test_is_geolocation_enabled_enabled_values() { + $reflection = new \ReflectionClass(Geolocation::class); + $method = $reflection->getMethod('is_geolocation_enabled'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $this->assertTrue($method->invoke(null, 'geolocation')); + $this->assertTrue($method->invoke(null, 'geolocation_ajax')); + } + + /** + * Test is_geolocation_enabled with disabled values. + */ + public function test_is_geolocation_enabled_disabled_values() { + $reflection = new \ReflectionClass(Geolocation::class); + $method = $reflection->getMethod('is_geolocation_enabled'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $this->assertFalse($method->invoke(null, '')); + $this->assertFalse($method->invoke(null, 'base')); + $this->assertFalse($method->invoke(null, 'shop')); + } + + /** + * Test geolocate_ip returns expected structure. + */ + public function test_geolocate_ip_structure() { + $result = Geolocation::geolocate_ip('127.0.0.1'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('ip', $result); + $this->assertArrayHasKey('country', $result); + $this->assertArrayHasKey('state', $result); + $this->assertEquals('127.0.0.1', $result['ip']); + } + + /** + * Test geolocate_ip with fallback for private IP. + */ + public function test_geolocate_ip_private_ip_with_fallback() { + $result = Geolocation::geolocate_ip('192.168.1.1', true); + + $this->assertIsArray($result); + $this->assertEquals('192.168.1.1', $result['ip']); + // Should either be empty or the filtered default country + $this->assertIsString($result['country']); + } + + /** + * Test clear_cache clears the memory cache. + */ + public function test_clear_cache() { + // First geolocate to populate cache + Geolocation::geolocate_ip('127.0.0.1'); + + // Clear cache + Geolocation::clear_cache(); + + $reflection = new \ReflectionClass(Geolocation::class); + + // Check memory cache is cleared + $memory_cache = $reflection->getProperty('memory_cache'); + if (PHP_VERSION_ID < 80100) { + $memory_cache->setAccessible(true); + } + $this->assertEquals([], $memory_cache->getValue()); + + // Check cached_ip is cleared + $cached_ip = $reflection->getProperty('cached_ip'); + if (PHP_VERSION_ID < 80100) { + $cached_ip->setAccessible(true); + } + $this->assertNull($cached_ip->getValue()); + } + + /** + * Test get_external_ip_address returns placeholder for private IP. + */ + public function test_get_external_ip_address_private_ip() { + // Mock REMOTE_ADDR to be a private IP + $_SERVER['REMOTE_ADDR'] = '192.168.1.1'; + + $result = Geolocation::get_external_ip_address(); + + $this->assertEquals('0.0.0.0', $result); + + // Clean up + unset($_SERVER['REMOTE_ADDR']); + $this->reset_static_properties(); + } + + /** + * Test get_external_ip_address returns IP for public address. + */ + public function test_get_external_ip_address_public_ip() { + // Mock REMOTE_ADDR to be a public IP + $_SERVER['REMOTE_ADDR'] = '8.8.8.8'; + + $result = Geolocation::get_external_ip_address(); + + $this->assertEquals('8.8.8.8', $result); + + // Clean up + unset($_SERVER['REMOTE_ADDR']); + $this->reset_static_properties(); + } + + /** + * Test get_country_from_headers with Cloudflare header. + */ + public function test_get_country_from_headers_cloudflare() { + $_SERVER['HTTP_CF_IPCOUNTRY'] = 'US'; + + $reflection = new \ReflectionClass(Geolocation::class); + $method = $reflection->getMethod('get_country_from_headers'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $result = $method->invoke(null); + $this->assertEquals('US', $result); + + // Clean up + unset($_SERVER['HTTP_CF_IPCOUNTRY']); + } + + /** + * Test get_country_from_headers with invalid country code. + */ + public function test_get_country_from_headers_invalid_code() { + $_SERVER['HTTP_CF_IPCOUNTRY'] = 'USA'; // Invalid: 3 letters + + $reflection = new \ReflectionClass(Geolocation::class); + $method = $reflection->getMethod('get_country_from_headers'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $result = $method->invoke(null); + $this->assertEquals('', $result); + + // Clean up + unset($_SERVER['HTTP_CF_IPCOUNTRY']); + } + + /** + * Test get_country_from_headers with no headers. + */ + public function test_get_country_from_headers_no_headers() { + $reflection = new \ReflectionClass(Geolocation::class); + $method = $reflection->getMethod('get_country_from_headers'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $result = $method->invoke(null); + $this->assertEquals('', $result); + } + + /** + * Test maybe_update_database returns new value. + */ + public function test_maybe_update_database_returns_new_value() { + $result = Geolocation::maybe_update_database('new_value', 'old_value'); + $this->assertEquals('new_value', $result); + } + + /** + * Test update_database is deprecated but callable. + */ + public function test_update_database_deprecated_callable() { + // Should not throw an error + Geolocation::update_database(); + $this->assertTrue(true); + } + + /** + * Test geolocate_ip caching with same IP. + */ + public function test_geolocate_ip_caching() { + $ip = '127.0.0.1'; + + // First call + $result1 = Geolocation::geolocate_ip($ip); + + // Second call should return from cache + $result2 = Geolocation::geolocate_ip($ip); + + $this->assertEquals($result1, $result2); + } + + /** + * Test close_reader handles null reader gracefully. + */ + public function test_close_reader_null() { + // Should not throw an error when reader is null + Geolocation::close_reader(); + $this->assertTrue(true); + } + + /** + * Test disable_geolocation_on_legacy_php returns unchanged value. + */ + public function test_disable_geolocation_on_legacy_php() { + $result = Geolocation::disable_geolocation_on_legacy_php('some_value'); + $this->assertEquals('some_value', $result); + } + + /** + * Test validate_ip with various edge cases. + * + * @param string $input The IP address to validate. + * @param string $expected The expected result. + * @dataProvider validate_ip_provider + */ + public function test_validate_ip_edge_cases($input, $expected) { + $reflection = new \ReflectionClass(Geolocation::class); + $method = $reflection->getMethod('validate_ip'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $result = $method->invoke(null, $input); + $this->assertEquals($expected, $result); + } + + /** + * Data provider for validate_ip edge cases. + */ + public static function validate_ip_provider() { + return [ + 'empty_string' => ['', ''], + 'valid_ipv4' => ['192.168.1.1', '192.168.1.1'], + 'ipv4_with_port' => ['192.168.1.1:8080', '192.168.1.1'], + 'valid_ipv6' => ['::1', '::1'], + 'ipv6_with_port' => ['[::1]:8080', '::1'], + 'invalid_ip' => ['not-an-ip', ''], + 'partial_ip' => ['192.168', ''], + 'ipv4_with_brackets' => ['[192.168.1.1]', '192.168.1.1'], + ]; + } + + /** + * Test is_private_ip with various edge cases. + * + * @param string $input The IP address to check. + * @param bool $expected The expected result. + * @dataProvider is_private_ip_provider + */ + public function test_is_private_ip_edge_cases($input, $expected) { + $reflection = new \ReflectionClass(Geolocation::class); + $method = $reflection->getMethod('is_private_ip'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $result = $method->invoke(null, $input); + $this->assertEquals($expected, $result); + } + + /** + * Data provider for is_private_ip edge cases. + */ + public static function is_private_ip_provider() { + return [ + 'empty_string' => ['', true], + 'private_192_168' => ['192.168.1.1', true], + 'private_10_0_0' => ['10.0.0.1', true], + 'private_172_16' => ['172.16.0.1', true], + 'private_172_31' => ['172.31.255.255', true], + 'private_loopback' => ['127.0.0.1', true], + 'private_ipv6_loopback' => ['::1', true], + 'private_ipv6' => ['fe80::1', true], + 'public_google_dns' => ['8.8.8.8', false], + 'public_cloudflare' => ['1.1.1.1', false], + 'public_quad9' => ['9.9.9.9', false], + ]; + } +} From fc7cfce82a3e635e270e54f36ba4da845253d622 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 12 Mar 2026 19:24:06 -0600 Subject: [PATCH 3/5] chore: trigger GitHub Actions From 04675173f3408454054e418004abae8a4dd6129a Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 12 Mar 2026 19:44:31 -0600 Subject: [PATCH 4/5] fix(geolocation): fix inverted null check in get_ip_address and wrong assertion method in tests --- inc/class-geolocation.php | 7 +++++-- tests/WP_Ultimo/Geolocation_Test.php | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/inc/class-geolocation.php b/inc/class-geolocation.php index bfbd50f5..a8c4807a 100644 --- a/inc/class-geolocation.php +++ b/inc/class-geolocation.php @@ -102,7 +102,7 @@ public static function close_reader(): void { */ public static function get_ip_address(): string { // Return cached IP if available (same request optimization) - if (null === self::$cached_ip) { + if (null !== self::$cached_ip) { return self::$cached_ip; } @@ -366,6 +366,8 @@ public static function update_database(): void { // This method is kept for backwards compatibility } + // phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + /** * Maybe trigger a DB update for the first time. * @@ -376,9 +378,10 @@ public static function update_database(): void { */ public static function maybe_update_database( string $new_value, - // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed string $old_value ): string { + + // phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed return $new_value; } diff --git a/tests/WP_Ultimo/Geolocation_Test.php b/tests/WP_Ultimo/Geolocation_Test.php index dc90f229..f4817454 100644 --- a/tests/WP_Ultimo/Geolocation_Test.php +++ b/tests/WP_Ultimo/Geolocation_Test.php @@ -207,7 +207,7 @@ public function test_get_local_database_path_structure() { $this->assertStringEndsWith('GeoLite2-Country.mmdb', $path); // Should contain wp-content/uploads - $this->assertStringContains('uploads', $path); + $this->assertStringContainsString('uploads', $path); } /** From 015ecd69b11afb006dc41a1546e77256069531b5 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 12 Mar 2026 19:56:24 -0600 Subject: [PATCH 5/5] fix(tests): save/restore $_SERVER in Geolocation tests to avoid polluting other test suites --- tests/WP_Ultimo/Geolocation_Test.php | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/WP_Ultimo/Geolocation_Test.php b/tests/WP_Ultimo/Geolocation_Test.php index f4817454..621d3038 100644 --- a/tests/WP_Ultimo/Geolocation_Test.php +++ b/tests/WP_Ultimo/Geolocation_Test.php @@ -17,9 +17,27 @@ class Geolocation_Test extends WP_UnitTestCase { /** - * Reset static properties after each test. + * Original $_SERVER values to restore after each test. + * + * @var array + */ + private $original_server = []; + + /** + * Save original $_SERVER values before each test. + */ + public function setUp(): void { + parent::setUp(); + $this->original_server = $_SERVER; + } + + /** + * Reset static properties and restore $_SERVER after each test. */ public function tearDown(): void { + // Restore original $_SERVER to avoid polluting other tests + $_SERVER = $this->original_server; + // Reset the static memory cache and cached IP $this->reset_static_properties(); parent::tearDown();