diff --git a/composer.json b/composer.json index 9756d609..dae02ebf 100644 --- a/composer.json +++ b/composer.json @@ -64,7 +64,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 f602b034..dfef53f3 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": "a2974f39475e0a134a6001a6b05cdc7e", + "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 0038ab2b..a8c4807a 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', - ]; + private static ?\MaxMind\Db\Reader $reader = null; /** - * @var $geoip_apis array API endpoints for geolocating an IP address + * GeoLite2 DB URL (deprecated - we use local MaxMind database). + * + * @deprecated 3.4.0 */ - private static array $geoip_apis = [ - 'ipinfo.io' => 'https://ipinfo.io/%s/json', - 'ip-api.com' => 'http://ip-api.com/json/%s', - ]; + const GEOLITE2_DB = 'http://geolite.maxmind.com/download/geoip/database/GeoLite2-Country.tar.gz'; /** - * Check if server supports MaxMind GeoLite2 Reader. + * Hook in geolocation functionality. * - * @todo reactivate this. - * @since 3.4.0 + * @return void */ - private static function supports_geolite2(): bool { - return false; // version_compare( PHP_VERSION, '5.4.0', '>=' ); + public static function init(): void { + // Register shutdown handler to close MaxMind reader + \register_shutdown_function([self::class, 'close_reader']); } /** - * Check if geolocation is enabled. + * Close the MaxMind database reader. * - * @since 3.4.0 - * @param string $current_settings Current geolocation settings. + * @return void */ - private static function is_geolocation_enabled($current_settings): bool { - return in_array($current_settings, ['geolocation', 'geolocation_ajax'], true); + 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; + } } - /** - * Prevent geolocation via MaxMind when using legacy versions of php. + * Get current user IP Address with comprehensive proxy/CDN support. * - * @since 3.4.0 - * @param string $default_customer_address current value. - * @return string + * 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 disable_geolocation_on_legacy_php($default_customer_address) { - if ( self::is_geolocation_enabled($default_customer_address) ) { - $default_customer_address = 'base'; + public static function get_ip_address(): string { + // Return cached IP if available (same request optimization) + if (null !== self::$cached_ip) { + return self::$cached_ip; } - return $default_customer_address; - } - + $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'])); + } - /** - * 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']); - } + // Validate and cache + $validated_ip = self::validate_ip($ip); + self::$cached_ip = $validated_ip; - // 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']); - } + return $validated_ip; } - /** - * Maybe trigger a DB update for the first time. + * Validate an IP address. * - * @param string $new_value New value. - * @param string $old_value Old value. - * @return string + * @param string $ip The IP address to validate. + * @return string The validated IP or empty 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(); + 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]; } - return $new_value; + // Use WordPress's IP validation + return (string) \rest_is_ip_address($ip); } /** - * Get current user IP Address. + * Check if an IP is a private/local address. * - * @return string + * @param string $ip The IP address to check. + * @return bool True if private/local. */ - 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 + private static function is_private_ip(string $ip): bool { + if (empty($ip)) { + return true; } - return ''; + // Check for private/reserved ranges + return ! filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ); } - /** - * 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. + * Geolocate an IP address. * - * @return string + * 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 get_external_ip_address() { - $external_ip_address = '0.0.0.0'; + 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(); + } - if ( '' !== self::get_ip_address() ) { - $transient_name = 'external_ip_address_' . self::get_ip_address(); - $external_ip_address = get_transient($transient_name); + // Check in-memory cache first (same request) + if (isset(self::$memory_cache[ $ip_address ])) { + return self::$memory_cache[ $ip_address ]; } - 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); + // 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); - 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 (false === $country_code) { + // Perform database lookup + $country_code = self::geolocate_via_db($ip_address); - 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; + // Cache the result (even empty results to prevent repeated lookups) + \wp_cache_set($cache_key, $country_code ?: '', self::CACHE_GROUP, DAY_IN_SECONDS); } } - set_transient($transient_name, $external_ip_address, WEEK_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'); + } } - return $external_ip_address; - } + $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; + } /** - * Geolocate an IP address. + * Get country code from HTTP headers set by CDN/proxy. * - * @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 + * @return string Country code or empty string. */ - 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 = ''; - } + 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 + ]; - if ( ! $country_code && $fallback ) { - // May be a local environment - find external IP. - return self::geolocate_ip(self::get_external_ip_address(), false, $api_fallback); + 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 [ - 'ip' => $ip_address, - 'country' => $country_code, - 'state' => '', - ]; + return ''; } - /** - * Path to our local db. + * Geolocate using local MaxMind database. * - * @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. + * Uses the bundled MaxMind reader via Composer autoloader. * - * Extract files with PharData. Tool built into PHP since 5.3. + * @param string $ip_address The IP address to lookup. + * @return string Country code or empty string. */ - public static function update_database(): void { - $logger = wc_get_logger(); + private static function geolocate_via_db(string $ip_address): string { + $database_path = self::get_local_database_path(); - if ( ! self::supports_geolite2() ) { - $logger->notice('Requires PHP 5.4 to be able to download MaxMind GeoLite2 database', ['source' => 'geolocation']); - return; + if (! file_exists($database_path)) { + 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'); + 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); } - // 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'] - ); + $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); + } /** - * Use MAXMIND GeoLite database to geolocation the user. + * Get user IP Address using an external service. * - * @param string $ip_address IP address. - * @param string $database Database path. + * @deprecated External IP lookup is disabled for performance. + * Use get_ip_address() instead. * @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'; + 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'; } - $geolite = new \WC_Geolite_Integration($database); - - return $geolite->get_country_iso($ip_address); + 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'); + } /** - * Use APIs to Geolocate the user. + * Check if geolocation is enabled. * - * Geolocation APIs can be added through the use of the wu_geolocation_geoip_apis filter. - * Provide a name=>value pair for service-slug=>endpoint. + * @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. * - * If APIs are defined, one will be chosen at random to fulfil the request. After completing, the result - * will be cached in a transient. + * @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 + } + + // phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + + /** + * Maybe trigger a DB update for the first time. * - * @param string $ip_address IP address. + * @deprecated Database management must be handled separately. + * @param string $new_value New value. + * @param string $old_value Old value. * @return string */ - private static function geolocate_via_api($ip_address) { - $country_code = get_transient('geoip_' . $ip_address); + public static function maybe_update_database( + string $new_value, + string $old_value + ): string { - if ( false === $country_code ) { - $geoip_services = apply_filters('wu_geolocation_geoip_apis', self::$geoip_apis); - - if ( empty($geoip_services) ) { - return ''; - } + // phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + return $new_value; + } - $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; - } - } - } + /** + * 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; + } - set_transient('geoip_' . $ip_address, $country_code, WEEK_IN_SECONDS); + /** + * 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); } - - return $country_code; } } diff --git a/tests/WP_Ultimo/Geolocation_Test.php b/tests/WP_Ultimo/Geolocation_Test.php new file mode 100644 index 00000000..621d3038 --- /dev/null +++ b/tests/WP_Ultimo/Geolocation_Test.php @@ -0,0 +1,531 @@ +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(); + } + + /** + * 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->assertStringContainsString('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], + ]; + } +}