/cloudscale-cyber-devtools/geo/dbip-city-lite.mmdb (the plugin-slug * folder WordPress.org asks for; /csdt-geo before 1.10.889, moved by maybe_migrate_legacy_dir()). * If the Analytics plugin's file is present at cspv-geo/, that is used as a * fallback so the 30 MB database isn't stored twice on the same site. */ class CSDT_Geo { // ── Country resolution ──────────────────────────────────────────────── /** * Return a 2-letter ISO country code for the given IP. * Tries CF-IPCountry first, then DB-IP Lite. */ public static function get_country( string $ip ): string { $cf_cc = isset( $_SERVER['HTTP_CF_IPCOUNTRY'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_IPCOUNTRY'] ) ) ) : ''; if ( strlen( $cf_cc ) === 2 && ctype_alpha( $cf_cc ) && $cf_cc !== 'XX' ) { return $cf_cc; } return self::lookup_dbip( $ip ); } /** * DB-IP Lite lookup, returns 2-letter ISO code or '' on miss. */ public static function lookup_dbip( string $ip ): string { if ( empty( $ip ) ) { return ''; } if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false ) { return ''; } $mmdb_path = self::mmdb_path(); if ( '' === $mmdb_path ) { return ''; } $autoload = plugin_dir_path( __DIR__ ) . 'lib/maxmind-db/autoload.php'; if ( ! file_exists( $autoload ) ) { return ''; } require_once $autoload; static $reader = null; static $reader_path = ''; if ( null === $reader || $reader_path !== $mmdb_path ) { try { $reader = new \CSDT\MaxMind\Db\Reader( $mmdb_path ); $reader_path = $mmdb_path; } catch ( \Exception $e ) { // A corrupt or incompatible DB file makes every lookup fail. Log it so // the admin can diagnose "no countries on the map" instead of seeing a // blank result with no explanation. if ( is_callable( array( 'CSDT_DevTools', 'log' ) ) ) { CSDT_DevTools::log( 'CSDT_Geo: MaxMind DB reader failed to open ' . $mmdb_path . ': ' . $e->getMessage() ); } $reader = null; return ''; } } try { $record = $reader->get( $ip ); if ( is_array( $record ) && isset( $record['country']['iso_code'] ) ) { return strtoupper( substr( $record['country']['iso_code'], 0, 2 ) ); } } catch ( \Exception $e ) { // Invalid IP or corrupt DB file. Log once per request so a corrupt DB // is not lost in a flood of identical empty results. if ( is_callable( array( 'CSDT_DevTools', 'log' ) ) ) { CSDT_DevTools::log( 'CSDT_Geo: MaxMind DB lookup failed for ' . $ip . ': ' . $e->getMessage() ); } } return ''; } // ── Database file management ────────────────────────────────────────── /** Option set once the pre-1.10.889 csdt-geo folder has been dealt with. */ const LEGACY_MIGRATED_OPTION = 'csdt_geo_dir_migrated'; /** * The GeoIP folder: /cloudscale-cyber-devtools/geo. Named after the plugin * slug, as WordPress.org asks of a plugin's uploads folder, and the only place the * path is built. * * @return string Absolute path, no trailing slash. */ public static function geo_dir(): string { return wp_upload_dir()['basedir'] . self::GEO_SUBDIR; } /** * geo_dir() relative to the uploads base. Every write builds its path inline as * wp_upload_dir()['basedir'] . self::GEO_SUBDIR, so the line that writes shows where. */ const GEO_SUBDIR = '/cloudscale-cyber-devtools/geo'; /** The folder used before 1.10.889. Read-only fallback until migrated. */ private static function legacy_geo_dir(): string { return wp_upload_dir()['basedir'] . '/csdt-geo'; } /** * Moves a database downloaded before 1.10.889 from /csdt-geo into geo_dir() * and removes the old folder, so an update does not force a fresh 60 MB download. * Runs once (flag option), through WP_Filesystem, and only on admin or cron requests, * never while serving a visitor. If the move fails the old file stays readable (see * mmdb_path()) and the next monthly download writes to the new folder anyway. * * @return void */ public static function maybe_migrate_legacy_dir(): void { if ( get_option( self::LEGACY_MIGRATED_OPTION ) ) { return; } $legacy = self::legacy_geo_dir(); if ( ! is_dir( $legacy ) ) { update_option( self::LEGACY_MIGRATED_OPTION, '1', false ); return; } require_once ABSPATH . 'wp-admin/includes/file.php'; if ( ! WP_Filesystem() ) { return; // Try again on a later request; the old file stays readable. } global $wp_filesystem; $new_dir = wp_upload_dir()['basedir'] . self::GEO_SUBDIR; wp_mkdir_p( $new_dir ); self::protect_own_dir(); $from = $legacy . '/dbip-city-lite.mmdb'; $to = $new_dir . '/dbip-city-lite.mmdb'; if ( $wp_filesystem->exists( $from ) && ! $wp_filesystem->exists( $to ) && ! $wp_filesystem->move( $from, $to ) ) { return; // Not moved: keep reading the old one, retry later. } $wp_filesystem->rmdir( $legacy, true ); update_option( self::LEGACY_MIGRATED_OPTION, '1', false ); } /** * Absolute path to the active mmdb file, or '' if not installed. * Prefers the DevTools-own path; falls back to the Analytics plugin's * shared file so installations with both plugins share a single copy. */ public static function mmdb_path(): string { $upload_dir = wp_upload_dir(); $own_path = self::geo_dir() . '/dbip-city-lite.mmdb'; if ( file_exists( $own_path ) ) { return $own_path; } // Until an admin or cron request has moved it (maybe_migrate_legacy_dir()), a // database downloaded before 1.10.889 is still read from its old folder. $legacy = self::legacy_geo_dir() . '/dbip-city-lite.mmdb'; if ( file_exists( $legacy ) ) { return $legacy; } $shared = $upload_dir['basedir'] . '/cspv-geo/dbip-city-lite.mmdb'; if ( file_exists( $shared ) ) { return $shared; } return ''; } /** * Writes an Apache access-deny rule into the GeoIP directory if it has none. * * The directory sits in wp-content/uploads, so it is web-reachable by default. The * DB-IP Lite file is a public download and not a secret, but there is no reason to * serve a multi-megabyte binary to anyone who guesses the path, and every other * directory this plugin creates under uploads carries the same rule (see * CSDT_DevTools::get_log_dir()). Called on download rather than on lookup: * lookups run per visitor on the Visitor Map, and this is a filesystem stat. * * Takes no path argument, deliberately. The first version of this took the directory * to protect, and `render_dbip_status()` passed it `dirname( mmdb_path() )`, which * resolves to the ANALYTICS plugin's `cspv-geo` folder whenever the shared database * is the one in use. Writing into another plugin's directory is its own WordPress.org * violation. So the path is derived from wp_upload_dir() here, in one * place, and can only ever be this plugin's own folder. * * @since 1.10.575 * @return void */ private static function protect_own_dir(): void { // The plugin-slug folder, not just geo/: an Apache deny rule covers every subfolder. $dir = dirname( self::geo_dir() ); $htaccess = $dir . '/.htaccess'; if ( is_dir( $dir ) && ! file_exists( $htaccess ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- csdt-submission-ok: one-time access-deny rule in this plugin's own wp_upload_dir() folder, not plugin data; @ suppresses a notice on read-only filesystems where the file simply won't be created @file_put_contents( $htaccess, "Require all denied\nDeny from all\n" ); } } /** * Download (or update) the DB-IP Lite mmdb for the current month. * * @return array|WP_Error array { size } on success, WP_Error on failure. */ public static function download_dbip() { $geo_dir = wp_upload_dir()['basedir'] . self::GEO_SUBDIR; $mmdb_path = $geo_dir . '/dbip-city-lite.mmdb'; $gz_path = $geo_dir . '/dbip-city-lite.mmdb.gz'; if ( ! file_exists( $geo_dir ) ) { wp_mkdir_p( $geo_dir ); } // Runs every download, not just the first: an install that predates this rule // picks it up on its next monthly refresh. self::protect_own_dir(); $year = gmdate( 'Y' ); $month = gmdate( 'm' ); $url = "https://download.db-ip.com/free/dbip-city-lite-{$year}-{$month}.mmdb.gz"; $response = wp_remote_get( $url, array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 120, 'stream' => true, 'filename' => $gz_path, ) ); if ( is_wp_error( $response ) ) { return new WP_Error( 'download_failed', CSDT_Error_Text::in_seconds( $response->get_error_message() ) ); } $code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { if ( file_exists( $gz_path ) ) { wp_delete_file( $gz_path ); } return new WP_Error( 'http_error', "HTTP {$code}, the file for this month may not be available yet." ); } $gz = gzopen( $gz_path, 'rb' ); if ( ! $gz ) { if ( file_exists( $gz_path ) ) { wp_delete_file( $gz_path ); } return new WP_Error( 'gz_open_failed', 'Could not open downloaded file.' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $out = fopen( $mmdb_path, 'wb' ); if ( ! $out ) { gzclose( $gz ); if ( file_exists( $gz_path ) ) { wp_delete_file( $gz_path ); } return new WP_Error( 'write_failed', 'Could not write database file.' ); } // Cap total decompressed output so a corrupt or MITM'd upstream can't inflate // a small .gz into a disk-filling write (zip-bomb style). The DB-IP Lite file // is well under this; anything larger is treated as hostile/corrupt. $max_bytes = 300 * 1024 * 1024; // 300 MB ceiling $written = 0; $overflow = false; while ( ! gzeof( $gz ) ) { $chunk = gzread( $gz, 8192 ); if ( '' === $chunk || false === $chunk ) { break; } $written += strlen( $chunk ); if ( $written > $max_bytes ) { $overflow = true; break; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite fwrite( $out, $chunk ); } gzclose( $gz ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose fclose( $out ); if ( file_exists( $gz_path ) ) { wp_delete_file( $gz_path ); } if ( $overflow ) { if ( file_exists( $mmdb_path ) ) { wp_delete_file( $mmdb_path ); } return new WP_Error( 'file_too_large', 'Downloaded database exceeded the size limit and was discarded.' ); } $size = filesize( $mmdb_path ); if ( $size < 1000000 ) { if ( file_exists( $mmdb_path ) ) { wp_delete_file( $mmdb_path ); } return new WP_Error( 'file_too_small', 'Downloaded file is too small (' . size_format( $size ) . ').' ); } update_option( 'csdt_dbip_last_updated', current_time( 'mysql' ) ); update_option( 'csdt_dbip_installed_ym', gmdate( 'Y-m' ) ); return array( 'size' => size_format( $size ) ); } // ── AJAX handler ────────────────────────────────────────────────────── public static function ajax_download_dbip(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized.' ); } $result = self::download_dbip(); if ( is_wp_error( $result ) ) { wp_send_json_error( CSDT_Error_Text::in_seconds( $result->get_error_message() ) ); } wp_send_json_success( $result ); } public static function ajax_save_dbip_settings(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized.' ); } $auto = isset( $_POST['auto_update'] ) && 'yes' === sanitize_key( wp_unslash( $_POST['auto_update'] ) ) ? 'yes' : 'no'; update_option( 'csdt_dbip_auto_update', $auto ); wp_send_json_success(); } // ── Cron auto-update ───────────────────────────────────────────────── /** * WP-Cron callback: download a fresh DB-IP Lite file if the installed * copy is from a previous month or is missing. */ public static function auto_update_run(): void { if ( get_option( 'csdt_dbip_auto_update', 'yes' ) !== 'yes' ) { return; } // Refresh a database the administrator chose to install HERE; never fetch the first // one. The first download is the button in the panel. mmdb_path() is not the test: // it also finds the copy another CloudScale plugin keeps in uploads/cspv-geo, and a // site with only that copy would download on its first cron run, which nobody had // asked this plugin to do. if ( ! file_exists( self::geo_dir() . '/dbip-city-lite.mmdb' ) && ! file_exists( self::legacy_geo_dir() . '/dbip-city-lite.mmdb' ) ) { return; } $installed_ym = get_option( 'csdt_dbip_installed_ym', '' ); if ( $installed_ym === gmdate( 'Y-m' ) ) { return; } self::download_dbip(); } // ── UI helpers ──────────────────────────────────────────────────────── /** * Render the DB-IP status + download/toggle row. * Called inline in the Attack Origins section of cloudscale-cyber-devtools.php. */ public static function render_dbip_status(): void { $mmdb_path = self::mmdb_path(); $installed = '' !== $mmdb_path; // Self-heal an install that predates the access-deny rule, rather than making it // wait for the next monthly refresh. Admin-side render only, so this filesystem // stat never touches a visitor request. Protects only our own folder, never the // Analytics plugin's shared cspv-geo, which mmdb_path() may be pointing at. self::protect_own_dir(); $last_updated = get_option( 'csdt_dbip_last_updated', '' ); $auto_update = get_option( 'csdt_dbip_auto_update', 'yes' ); $size_fmt = $installed ? size_format( filesize( $mmdb_path ) ) : ''; $updated_fmt = $last_updated ? wp_date( 'j M Y', strtotime( $last_updated ) ) : ''; ?>
✓ DB-IP Lite installed  ,    ,  updated  (country shown for all sites, not just Cloudflare) ⚠️ DB-IP Lite not installed  ,  country data requires Cloudflare CF-IPCountry header
English short name, for the Country Rules picker. * * Nothing in this plugin previously had a comprehensive list: CSDT_Geo resolves a * bare code from Cloudflare/DB-IP and stops there, and the only existing name map * (cs-login.js's bfCountryNames, for attack-map tooltips) covers ~62 countries seen * in traffic so far, not the full standard, not enough to back a picker where an * admin searches by name for a country that has simply never attacked them yet. * * @return array */ public static function all_countries(): array { return array( 'AD' => 'Andorra', 'AE' => 'United Arab Emirates', 'AF' => 'Afghanistan', 'AG' => 'Antigua and Barbuda', 'AI' => 'Anguilla', 'AL' => 'Albania', 'AM' => 'Armenia', 'AO' => 'Angola', 'AQ' => 'Antarctica', 'AR' => 'Argentina', 'AS' => 'American Samoa', 'AT' => 'Austria', 'AU' => 'Australia', 'AW' => 'Aruba', 'AX' => 'Åland Islands', 'AZ' => 'Azerbaijan', 'BA' => 'Bosnia and Herzegovina', 'BB' => 'Barbados', 'BD' => 'Bangladesh', 'BE' => 'Belgium', 'BF' => 'Burkina Faso', 'BG' => 'Bulgaria', 'BH' => 'Bahrain', 'BI' => 'Burundi', 'BJ' => 'Benin', 'BL' => 'Saint Barthélemy', 'BM' => 'Bermuda', 'BN' => 'Brunei', 'BO' => 'Bolivia', 'BQ' => 'Bonaire, Sint Eustatius and Saba', 'BR' => 'Brazil', 'BS' => 'Bahamas', 'BT' => 'Bhutan', 'BV' => 'Bouvet Island', 'BW' => 'Botswana', 'BY' => 'Belarus', 'BZ' => 'Belize', 'CA' => 'Canada', 'CC' => 'Cocos (Keeling) Islands', 'CD' => 'Congo (DRC)', 'CF' => 'Central African Republic', 'CG' => 'Congo', 'CH' => 'Switzerland', 'CI' => 'Côte d\'Ivoire', 'CK' => 'Cook Islands', 'CL' => 'Chile', 'CM' => 'Cameroon', 'CN' => 'China', 'CO' => 'Colombia', 'CR' => 'Costa Rica', 'CU' => 'Cuba', 'CV' => 'Cabo Verde', 'CW' => 'Curaçao', 'CX' => 'Christmas Island', 'CY' => 'Cyprus', 'CZ' => 'Czechia', 'DE' => 'Germany', 'DJ' => 'Djibouti', 'DK' => 'Denmark', 'DM' => 'Dominica', 'DO' => 'Dominican Republic', 'DZ' => 'Algeria', 'EC' => 'Ecuador', 'EE' => 'Estonia', 'EG' => 'Egypt', 'EH' => 'Western Sahara', 'ER' => 'Eritrea', 'ES' => 'Spain', 'ET' => 'Ethiopia', 'FI' => 'Finland', 'FJ' => 'Fiji', 'FK' => 'Falkland Islands', 'FM' => 'Micronesia', 'FO' => 'Faroe Islands', 'FR' => 'France', 'GA' => 'Gabon', 'GB' => 'United Kingdom', 'GD' => 'Grenada', 'GE' => 'Georgia', 'GF' => 'French Guiana', 'GG' => 'Guernsey', 'GH' => 'Ghana', 'GI' => 'Gibraltar', 'GL' => 'Greenland', 'GM' => 'Gambia', 'GN' => 'Guinea', 'GP' => 'Guadeloupe', 'GQ' => 'Equatorial Guinea', 'GR' => 'Greece', 'GS' => 'South Georgia and the South Sandwich Islands', 'GT' => 'Guatemala', 'GU' => 'Guam', 'GW' => 'Guinea-Bissau', 'GY' => 'Guyana', 'HK' => 'Hong Kong', 'HM' => 'Heard Island and McDonald Islands', 'HN' => 'Honduras', 'HR' => 'Croatia', 'HT' => 'Haiti', 'HU' => 'Hungary', 'ID' => 'Indonesia', 'IE' => 'Ireland', 'IL' => 'Israel', 'IM' => 'Isle of Man', 'IN' => 'India', 'IO' => 'British Indian Ocean Territory', 'IQ' => 'Iraq', 'IR' => 'Iran', 'IS' => 'Iceland', 'IT' => 'Italy', 'JE' => 'Jersey', 'JM' => 'Jamaica', 'JO' => 'Jordan', 'JP' => 'Japan', 'KE' => 'Kenya', 'KG' => 'Kyrgyzstan', 'KH' => 'Cambodia', 'KI' => 'Kiribati', 'KM' => 'Comoros', 'KN' => 'Saint Kitts and Nevis', 'KP' => 'North Korea', 'KR' => 'South Korea', 'KW' => 'Kuwait', 'KY' => 'Cayman Islands', 'KZ' => 'Kazakhstan', 'LA' => 'Laos', 'LB' => 'Lebanon', 'LC' => 'Saint Lucia', 'LI' => 'Liechtenstein', 'LK' => 'Sri Lanka', 'LR' => 'Liberia', 'LS' => 'Lesotho', 'LT' => 'Lithuania', 'LU' => 'Luxembourg', 'LV' => 'Latvia', 'LY' => 'Libya', 'MA' => 'Morocco', 'MC' => 'Monaco', 'MD' => 'Moldova', 'ME' => 'Montenegro', 'MF' => 'Saint Martin', 'MG' => 'Madagascar', 'MH' => 'Marshall Islands', 'MK' => 'North Macedonia', 'ML' => 'Mali', 'MM' => 'Myanmar', 'MN' => 'Mongolia', 'MO' => 'Macao', 'MP' => 'Northern Mariana Islands', 'MQ' => 'Martinique', 'MR' => 'Mauritania', 'MS' => 'Montserrat', 'MT' => 'Malta', 'MU' => 'Mauritius', 'MV' => 'Maldives', 'MW' => 'Malawi', 'MX' => 'Mexico', 'MY' => 'Malaysia', 'MZ' => 'Mozambique', 'NA' => 'Namibia', 'NC' => 'New Caledonia', 'NE' => 'Niger', 'NF' => 'Norfolk Island', 'NG' => 'Nigeria', 'NI' => 'Nicaragua', 'NL' => 'Netherlands', 'NO' => 'Norway', 'NP' => 'Nepal', 'NR' => 'Nauru', 'NU' => 'Niue', 'NZ' => 'New Zealand', 'OM' => 'Oman', 'PA' => 'Panama', 'PE' => 'Peru', 'PF' => 'French Polynesia', 'PG' => 'Papua New Guinea', 'PH' => 'Philippines', 'PK' => 'Pakistan', 'PL' => 'Poland', 'PM' => 'Saint Pierre and Miquelon', 'PN' => 'Pitcairn', 'PR' => 'Puerto Rico', 'PS' => 'Palestine', 'PT' => 'Portugal', 'PW' => 'Palau', 'PY' => 'Paraguay', 'QA' => 'Qatar', 'RE' => 'Réunion', 'RO' => 'Romania', 'RS' => 'Serbia', 'RU' => 'Russia', 'RW' => 'Rwanda', 'SA' => 'Saudi Arabia', 'SB' => 'Solomon Islands', 'SC' => 'Seychelles', 'SD' => 'Sudan', 'SE' => 'Sweden', 'SG' => 'Singapore', 'SH' => 'Saint Helena', 'SI' => 'Slovenia', 'SJ' => 'Svalbard and Jan Mayen', 'SK' => 'Slovakia', 'SL' => 'Sierra Leone', 'SM' => 'San Marino', 'SN' => 'Senegal', 'SO' => 'Somalia', 'SR' => 'Suriname', 'SS' => 'South Sudan', 'ST' => 'São Tomé and Príncipe', 'SV' => 'El Salvador', 'SX' => 'Sint Maarten', 'SY' => 'Syria', 'SZ' => 'Eswatini', 'TC' => 'Turks and Caicos Islands', 'TD' => 'Chad', 'TF' => 'French Southern Territories', 'TG' => 'Togo', 'TH' => 'Thailand', 'TJ' => 'Tajikistan', 'TK' => 'Tokelau', 'TL' => 'Timor-Leste', 'TM' => 'Turkmenistan', 'TN' => 'Tunisia', 'TO' => 'Tonga', 'TR' => 'Turkey', 'TT' => 'Trinidad and Tobago', 'TV' => 'Tuvalu', 'TW' => 'Taiwan', 'TZ' => 'Tanzania', 'UA' => 'Ukraine', 'UG' => 'Uganda', 'US' => 'United States', 'UY' => 'Uruguay', 'UZ' => 'Uzbekistan', 'VA' => 'Vatican City', 'VC' => 'Saint Vincent and the Grenadines', 'VE' => 'Venezuela', 'VG' => 'British Virgin Islands', 'VI' => 'U.S. Virgin Islands', 'VN' => 'Vietnam', 'VU' => 'Vanuatu', 'WF' => 'Wallis and Futuna', 'WS' => 'Samoa', 'YE' => 'Yemen', 'YT' => 'Mayotte', 'ZA' => 'South Africa', 'ZM' => 'Zambia', 'ZW' => 'Zimbabwe', ); } /** English short name for a code, or the code itself if unknown. */ public static function country_name( string $code ): string { $code = strtoupper( $code ); return self::all_countries()[ $code ] ?? $code; } }