'', 'city' => '', 'lat' => null, 'lon' => null, 'source' => '', ); $record = self::record( self::visitor_ip() ); if ( $record ) { $found['source'] = 'ip'; $found['country'] = self::code( $record['country']['iso_code'] ?? '' ); $found['city'] = self::city_name( $record['city']['names'] ?? array() ); if ( '' !== $found['city'] ) { list( $found['lat'], $found['lon'] ) = self::point( $record['location']['latitude'] ?? null, $record['location']['longitude'] ?? null ); } } if ( '' === $found['country'] ) { $found['country'] = self::code( self::header( array( 'HTTP_CF_IPCOUNTRY', 'HTTP_CLOUDFRONT_VIEWER_COUNTRY', 'HTTP_X_VERCEL_IP_COUNTRY', 'HTTP_X_COUNTRY_CODE', 'GEOIP_COUNTRY_CODE' ) ) ); if ( '' !== $found['country'] ) { $found['city'] = self::clean_city( rawurldecode( self::header( array( 'HTTP_CF_IPCITY', 'HTTP_X_VERCEL_IP_CITY', 'GEOIP_CITY' ) ) ) ); if ( '' !== $found['city'] ) { list( $found['lat'], $found['lon'] ) = self::point( self::header( array( 'HTTP_CF_IPLATITUDE', 'HTTP_X_VERCEL_IP_LATITUDE', 'GEOIP_LATITUDE' ) ), self::header( array( 'HTTP_CF_IPLONGITUDE', 'HTTP_X_VERCEL_IP_LONGITUDE', 'GEOIP_LONGITUDE' ) ) ); } $found['source'] = 'cdn'; } } if ( '' === $found['country'] && '' !== (string) $time_zone ) { $found['country'] = self::country_of_time_zone( (string) $time_zone ); $found['source'] = '' !== $found['country'] ? 'tz' : ''; } return $found; } /** * Name of a country in the language of the admin. * * @param string $code Country code, e.g. "DE". * @return string */ public static function country_label( $code ) { $code = self::code( $code ); return '' === $code ? __( '(not set)', 'dealroot' ) : self::country_name( $code ); } /** * Name of a country in the language of the admin, the code where PHP has no names. * * @param string $code Country code. * @return string */ public static function country_name( $code ) { $code = self::code( $code ); if ( '' !== $code && class_exists( '\\Locale' ) ) { $name = \Locale::getDisplayRegion( 'und-' . $code, determine_locale() ); if ( is_string( $name ) && '' !== $name && $name !== $code ) { return $name; } } return $code; } /** * City and country, e.g. "Hannover, Germany". * * @param string $country Country code. * @param string $city City. * @return string */ public static function place( $country, $city ) { $country = self::code( $country ); $city = (string) $city; if ( '' === $country ) { return $city; } return ( '' !== $city ? $city . ', ' : '' ) . self::country_name( $country ); } /** * Valid two-letter country code, '' otherwise. "XX" and "T1" (Tor) of Cloudflare are unknown. * * @param mixed $code Raw code. * @return string */ public static function code( $code ) { $code = strtoupper( trim( (string) $code ) ); return preg_match( '/^[A-Z]{2}$/', $code ) && ! in_array( $code, array( 'XX', 'ZZ', 'EU', 'AP' ), true ) ? $code : ''; } /** * Place of a city on the map, to two decimals: about a kilometre, the centre of the city * rather than where the visitor is. * * @param mixed $lat Latitude. * @param mixed $lon Longitude. * @return array{0: float|null, 1: float|null} */ public static function point( $lat, $lon ) { if ( ! is_numeric( $lat ) || ! is_numeric( $lon ) || abs( (float) $lat ) > 90 || abs( (float) $lon ) > 180 || ( 0.0 === (float) $lat && 0.0 === (float) $lon ) ) { return array( null, null ); } return array( round( (float) $lat, 2 ), round( (float) $lon, 2 ) ); } /** * City name as stored: without the district DB-IP adds in brackets. * * @param string $city Raw name. * @return string */ public static function clean_city( $city ) { $city = trim( (string) preg_replace( '/\s*\([^)]*\)\s*$/u', '', sanitize_text_field( (string) $city ) ) ); return mb_substr( $city, 0, 100 ); } /** * Whether cities are looked up, and how the database is doing. * * @return array{enabled: bool, ready: bool, built: int, size: int, error: string, updating: bool} */ public static function status() { $state = get_option( self::STATE, array() ); $state = is_array( $state ) ? $state : array(); $path = self::path(); return array( 'enabled' => (bool) Settings::get( 'geo_cities' ), 'ready' => '' !== $path && is_readable( $path ), 'built' => (int) ( $state['built'] ?? 0 ), 'size' => '' !== $path && is_readable( $path ) ? (int) filesize( $path ) : 0, 'error' => (string) ( $state['error'] ?? '' ), 'updating' => (bool) wp_next_scheduled( self::UPDATE_HOOK ), ); } /** * Downloads the database when cities were just turned on, removes it when they were turned off. * * @param mixed $old_value Settings before. * @param mixed $value Settings after. */ public static function settings_saved( $old_value, $value ) { $was = ! empty( $old_value['geo_cities'] ); $now = ! empty( $value['geo_cities'] ); if ( $now && ( ! $was || ! self::status()['ready'] ) ) { self::schedule(); } elseif ( ! $now && $was ) { self::remove(); } } /** * Downloads the database right away, from the button in the settings. */ public static function handle_download() { check_admin_referer( 'dealroot_geo_download' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to change these settings.', 'dealroot' ), 403 ); } wp_clear_scheduled_hook( self::UPDATE_HOOK ); delete_transient( 'dealroot_geo_lock' ); self::update(); wp_safe_redirect( admin_url( 'admin.php?page=' . Admin\Admin::SETTINGS_PAGE . '#dealroot-section-tracking' ) ); exit; } /** * Replaces a database that is a month old. */ public static function maybe_update() { if ( ! Settings::get( 'geo_cities' ) ) { return; } $status = self::status(); if ( ! $status['ready'] || time() - $status['built'] > self::MAX_AGE ) { self::schedule(); } } /** * Downloads the database in the background, a few seconds from now. */ public static function schedule() { if ( ! wp_next_scheduled( self::UPDATE_HOOK ) ) { wp_schedule_single_event( time() + 5, self::UPDATE_HOOK ); } } /** * Downloads this month's database, or last month's while this month's is not out yet. * * @return bool */ public static function update() { if ( ! Settings::get( 'geo_cities' ) ) { return false; } // One download at a time, e.g. when the scheduled one and a manual one meet. if ( get_transient( 'dealroot_geo_lock' ) ) { return false; } set_transient( 'dealroot_geo_lock', 1, 15 * MINUTE_IN_SECONDS ); $done = self::download(); delete_transient( 'dealroot_geo_lock' ); return $done; } /** * Downloads, unpacks and checks the database, then puts it in place of the old one. * * @return bool */ private static function download() { require_once ABSPATH . 'wp-admin/includes/file.php'; if ( function_exists( 'set_time_limit' ) ) { set_time_limit( 900 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- About 60 MB are downloaded and unpacked. } $folder = self::folder(); if ( '' === $folder || ! wp_mkdir_p( $folder ) ) { return self::failed( __( 'The folder for the database could not be created in the uploads folder.', 'dealroot' ) ); } self::protect( $folder ); $download = null; foreach ( array( gmdate( 'Y-m' ), gmdate( 'Y-m', strtotime( 'first day of last month' ) ) ) as $month ) { /** * Filters the address of the location database, e.g. to load it from a mirror. * * @param string $url Address of a gzip-packed MaxMind DB file. * @param string $month Month of the database, e.g. "2026-10". */ $url = (string) apply_filters( 'dealroot_geo_database_url', sprintf( self::SOURCE, $month ), $month ); $download = download_url( $url, 900 ); if ( ! is_wp_error( $download ) ) { break; } } if ( is_wp_error( $download ) ) { /* translators: %s: error message */ return self::failed( sprintf( __( 'The database could not be downloaded: %s', 'dealroot' ), $download->get_error_message() ) ); } $target = $folder . '/' . self::FILE; $unpacked = $target . '.' . wp_generate_password( 8, false ) . '.new'; $ok = self::gunzip( $download, $unpacked ); wp_delete_file( $download ); try { $reader = $ok ? new Mmdb_Reader( $unpacked ) : null; $ok = $reader && $reader->get( '8.8.8.8' ); $built = $reader ? (int) ( $reader->metadata()['build_epoch'] ?? time() ) : 0; unset( $reader ); } catch ( \RuntimeException $e ) { $ok = false; } if ( ! $ok || ! rename( $unpacked, $target ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Replaces the database in one step. wp_delete_file( $unpacked ); return self::failed( __( 'The downloaded database could not be read.', 'dealroot' ) ); } self::$reader = null; update_option( self::STATE, array( 'built' => $built, 'error' => '', ), false ); return true; } /** * Removes the database. */ public static function remove() { wp_clear_scheduled_hook( self::UPDATE_HOOK ); $path = self::path(); if ( '' !== $path && file_exists( $path ) ) { wp_delete_file( $path ); } delete_option( self::STATE ); self::$reader = null; } /** * Record of an IP address in the most exact database there is: MaxMind when its place is * close enough, else DB-IP, else whatever MaxMind knows. * * @param string $ip IP address. * @return array|null */ private static function record( $ip ) { if ( '' === $ip || ! Settings::get( 'geo_cities' ) || ! self::is_public( $ip ) ) { return null; } $maxmind = Geo_Maxmind::lookup( $ip ); if ( Geo_Maxmind::exact( $maxmind ) ) { return $maxmind; } $dbip = self::lookup( $ip ); return $dbip && ( ! empty( $dbip['city']['names'] ) || ! $maxmind ) ? $dbip : ( $maxmind ? $maxmind : $dbip ); } /** * Record of an IP address in the DB-IP database. * * @param string $ip IP address. * @return array|null */ private static function lookup( $ip ) { if ( '' === $ip || ! Settings::get( 'geo_cities' ) || ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { return null; } if ( null === self::$reader ) { $path = self::path(); try { self::$reader = '' !== $path && is_readable( $path ) ? new Mmdb_Reader( $path ) : false; } catch ( \RuntimeException $e ) { self::$reader = false; } } if ( ! self::$reader ) { return null; } try { return self::$reader->get( $ip ); } catch ( \RuntimeException $e ) { return null; } } /** * City name in the language of the site, else in English. * * @param array $names Language => name. * @return string */ private static function city_name( $names ) { if ( ! is_array( $names ) || ! $names ) { return ''; } $language = substr( get_locale(), 0, 2 ); return self::clean_city( (string) ( $names[ $language ] ?? $names['en'] ?? reset( $names ) ) ); } /** * Country of a time zone, e.g. DE for Europe/Berlin. * * @param string $time_zone Time zone. * @return string */ private static function country_of_time_zone( $time_zone ) { if ( ! preg_match( '#^[A-Za-z]+(?:/[A-Za-z0-9_+\-]+){1,2}$#', $time_zone ) || ! in_array( $time_zone, timezone_identifiers_list(), true ) ) { return ''; } $location = ( new \DateTimeZone( $time_zone ) )->getLocation(); return is_array( $location ) ? self::code( $location['country_code'] ?? '' ) : ''; } /** * First request header that is set. * * @param string[] $names Server variable names. * @return string */ private static function header( array $names ) { foreach ( $names as $name ) { if ( ! empty( $_SERVER[ $name ] ) ) { return sanitize_text_field( wp_unslash( $_SERVER[ $name ] ) ); } } return ''; } /** * IP address of the visitor; behind Cloudflare the one it passes on. * * @return string */ private static function visitor_ip() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) && ! empty( $_SERVER['HTTP_CF_RAY'] ) ) { $forwarded = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ); $ip = filter_var( $forwarded, FILTER_VALIDATE_IP ) ? $forwarded : $ip; } elseif ( ! self::is_public( $ip ) ) { // The site sits behind a proxy of the host, e.g. a load balancer or a cache: the // visitor is the first public address it passes on. foreach ( array( 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_TRUE_CLIENT_IP', 'HTTP_X_CLIENT_IP' ) as $header ) { foreach ( explode( ',', self::header( array( $header ) ) ) as $candidate ) { $candidate = trim( $candidate ); if ( self::is_public( $candidate ) ) { $ip = $candidate; break 2; } } } } /** * Filters the IP address used to find the city of a visitor, e.g. behind a proxy that * passes it in a header of its own. It is never stored. * * @param string $ip IP address. */ return (string) apply_filters( 'dealroot_visitor_ip', $ip ); } /** * Whether an address is one of the internet, not of a private network. * * @param string $ip IP address. * @return bool */ private static function is_public( $ip ) { return (bool) filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); } /** * What the lookup finds for the admin looking at the settings, to show that it works, or * why it does not. * * @return array{ip: string, city: string, country: string, reason: string, databases: array} */ public static function check() { $ip = self::visitor_ip(); $record = self::record( $ip ); $reason = ''; // What each database says, so the owner can compare them. $databases = array(); if ( Geo_Maxmind::configured() && self::is_public( $ip ) ) { foreach ( array( 'MaxMind' => Geo_Maxmind::lookup( $ip ), 'DB-IP' => self::lookup( $ip ), ) as $name => $found ) { $databases[ $name ] = array( 'place' => $found ? self::place( self::code( $found['country']['iso_code'] ?? '' ), self::city_name( $found['city']['names'] ?? array() ) ) : '', 'radius' => $found ? (int) ( $found['location']['accuracy_radius'] ?? 0 ) : 0, 'used' => $found && $found === $record, ); } } if ( ! self::status()['ready'] && ! Geo_Maxmind::status()['ready'] ) { $reason = 'database'; } elseif ( ! self::is_public( $ip ) ) { $reason = 'private'; } elseif ( ! $record || empty( $record['city']['names'] ) ) { $reason = 'unknown'; } return array( // The last part of the address is left out on the screen as well. 'ip' => (string) preg_replace( '/[.:][^.:]+$/', '.…', $ip ), 'city' => $record ? self::city_name( $record['city']['names'] ?? array() ) : '', 'country' => $record ? self::code( $record['country']['iso_code'] ?? '' ) : '', 'reason' => $reason, 'databases' => $databases, ); } /** * Folder of the database in the uploads folder. * * @return string */ private static function folder() { $uploads = wp_upload_dir( null, false ); return empty( $uploads['basedir'] ) ? '' : trailingslashit( $uploads['basedir'] ) . 'dealroot'; } /** * Path of the database. * * @return string */ private static function path() { $folder = self::folder(); return '' === $folder ? '' : $folder . '/' . self::FILE; } /** * Keeps the folder from being listed or downloaded. * * @param string $folder Folder. */ private static function protect( $folder ) { global $wp_filesystem; if ( ! $wp_filesystem && ! WP_Filesystem() ) { return; } foreach ( array( 'index.php' => " "Require all denied\n", ) as $name => $content ) { if ( ! file_exists( $folder . '/' . $name ) ) { $wp_filesystem->put_contents( $folder . '/' . $name, $content, FS_CHMOD_FILE ); } } } /** * Unpacks a .gz file chunk by chunk. * * @param string $source Packed file. * @param string $target Unpacked file. * @return bool */ private static function gunzip( $source, $target ) { // phpcs:disable WordPress.WP.AlternativeFunctions -- Streams about 130 MB, which WP_Filesystem would load into memory. $in = function_exists( 'gzopen' ) ? gzopen( $source, 'rb' ) : false; $out = $in ? fopen( $target, 'wb' ) : false; if ( ! $in || ! $out ) { if ( $in ) { gzclose( $in ); } return false; } $ok = true; while ( $ok && ! gzeof( $in ) ) { $chunk = gzread( $in, 1048576 ); $ok = false !== $chunk && false !== fwrite( $out, $chunk ); } gzclose( $in ); fclose( $out ); // phpcs:enable return $ok; } /** * Keeps the reason a download failed for the settings screen. * * @param string $message Reason. * @return bool False. */ private static function failed( $message ) { $state = get_option( self::STATE, array() ); $state = is_array( $state ) ? $state : array(); $state['error'] = $message; update_option( self::STATE, $state, false ); return false; } }