fix(ip-geo): harden full-chain IP geolocation reliability
Capture real client IPs behind CDN, improve provider fallbacks and negative cache, fix admin credential dependsOn for polling mode, and map country labels to ISO codes for moderation GeoIP blocks.
This commit is contained in:
@@ -310,6 +310,19 @@ class ExtensionController extends ControllerBase {
|
||||
$message = '';
|
||||
$ok = !empty($result['country']) || !empty($result['province']) || !empty($result['city']);
|
||||
|
||||
// Persist "verified" once a live lookup succeeds so overview cards leave "待补全".
|
||||
if ($ok) {
|
||||
$all = \AKSRT\Settings\Settings::get_all();
|
||||
if (empty($all['ip_geo_verified'])) {
|
||||
$all['ip_geo_verified'] = true;
|
||||
update_option('aksrt_settings', $all);
|
||||
if (method_exists('\AKSRT\Settings\Settings', 'flush_settings_cache')) {
|
||||
\AKSRT\Settings\Settings::flush_settings_cache();
|
||||
}
|
||||
$status = $geo->get_status();
|
||||
}
|
||||
}
|
||||
|
||||
if (!$ok) {
|
||||
$detail = trim((string) ($result['detail'] ?? ''));
|
||||
switch ($reason) {
|
||||
|
||||
@@ -27,8 +27,8 @@ class SchemaExtensionsIP {
|
||||
['key' => 'ip_geo_cache_ttl', 'label' => '缓存时间 (秒)', 'type' => 'number', 'default' => 86400, 'min' => 3600, 'max' => 604800],
|
||||
]],
|
||||
['key' => 'providers', 'title' => '接口凭据', 'fields' => [
|
||||
['key' => 'ip_addr_sdk_qq_appkey', 'label' => '腾讯 AppKey', 'type' => 'input', 'default' => ''],
|
||||
['key' => 'ip_addr_sdk_qq_secretkey', 'label' => '腾讯 SecretKey', 'type' => 'input', 'default' => ''],
|
||||
['key' => 'ip_addr_sdk_qq_appkey', 'label' => '腾讯 AppKey', 'type' => 'input', 'default' => '', 'help' => '「腾讯位置服务」或「轮流查询」时填写'],
|
||||
['key' => 'ip_addr_sdk_qq_secretkey', 'label' => '腾讯 SecretKey', 'type' => 'input', 'default' => '', 'help' => '启用 SK 签名时必填'],
|
||||
['key' => 'ip_addr_sdk_amap_appkey', 'label' => '高德 AppKey', 'type' => 'input', 'default' => '', 'help' => '须为「Web服务」Key,并开通 IP 定位'],
|
||||
['key' => 'ip_addr_sdk_amap_secretkey', 'label' => '高德 SecretKey', 'type' => 'input', 'default' => '', 'help' => '启用数字签名时必填'],
|
||||
]],
|
||||
|
||||
@@ -92,25 +92,76 @@ trait ModerationPart2 {
|
||||
}
|
||||
|
||||
private function lookup_geoip(string $ip, array $config): ?string {
|
||||
// 优先复用 IP 归属地模块的查询能力(含缓存)
|
||||
if (class_exists('\AKSRT\Extensions\IPGeo')) {
|
||||
$result = \AKSRT\Extensions\IPGeo::get_instance()->lookup($ip);
|
||||
if (!empty($result['country'])) {
|
||||
return $result['country'];
|
||||
// Dedicated ISO country-code path for block lists (CN/US/…).
|
||||
// IPGeo returns localized names ("中国") which are not ISO codes.
|
||||
$provider = sanitize_key((string) ($config['geoip_provider'] ?? 'ipapi'));
|
||||
$api_key = (string) ($config['geoip_api_key'] ?? '');
|
||||
|
||||
if ($provider === 'ipinfo' || $provider === 'ipinfo.io') {
|
||||
$code = $this->geoip_ipinfo($ip, $api_key);
|
||||
if ($code !== null) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到内置查询
|
||||
$ip_geo_config = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_ip_geo_settings()
|
||||
: [];
|
||||
$provider = (string) ($ip_geo_config['ip_geo_provider'] ?? $config['geoip_provider'] ?? 'ip-api.com');
|
||||
$api_key = (string) ($ip_geo_config['ip_geo_api_key'] ?? $config['geoip_api_key'] ?? '');
|
||||
|
||||
if ($provider === 'ipinfo' || $provider === 'ipinfo.io') {
|
||||
return $this->geoip_ipinfo($ip, $api_key);
|
||||
$code = $this->geoip_ipapi($ip);
|
||||
if ($code !== null) {
|
||||
return $code;
|
||||
}
|
||||
return $this->geoip_ipapi($ip);
|
||||
|
||||
// Last resort: map IPGeo country label → ISO when possible.
|
||||
if (class_exists('\AKSRT\Extensions\IPGeo')) {
|
||||
$result = \AKSRT\Extensions\IPGeo::get_instance()->lookup($ip);
|
||||
$label = trim((string) ($result['country'] ?? ''));
|
||||
if ($label !== '') {
|
||||
return $this->country_label_to_iso($label);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map common country labels / ISO aliases to ISO-3166 alpha-2.
|
||||
*/
|
||||
private function country_label_to_iso(string $label): ?string {
|
||||
$raw = trim($label);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
if (preg_match('/^[A-Za-z]{2}$/', $raw)) {
|
||||
return strtoupper($raw);
|
||||
}
|
||||
$map = [
|
||||
'中国' => 'CN', '中华人民共和国' => 'CN', 'china' => 'CN', 'cn' => 'CN',
|
||||
'美国' => 'US', '美利坚合众国' => 'US', 'united states' => 'US', 'usa' => 'US', 'us' => 'US',
|
||||
'日本' => 'JP', 'japan' => 'JP', 'jp' => 'JP',
|
||||
'韩国' => 'KR', '大韩民国' => 'KR', 'south korea' => 'KR', 'korea' => 'KR', 'kr' => 'KR',
|
||||
'香港' => 'HK', '中国香港' => 'HK', 'hong kong' => 'HK', 'hk' => 'HK',
|
||||
'澳门' => 'MO', '中国澳门' => 'MO', 'macao' => 'MO', 'macau' => 'MO', 'mo' => 'MO',
|
||||
'台湾' => 'TW', '中国台湾' => 'TW', 'taiwan' => 'TW', 'tw' => 'TW',
|
||||
'新加坡' => 'SG', 'singapore' => 'SG', 'sg' => 'SG',
|
||||
'英国' => 'GB', 'united kingdom' => 'GB', 'uk' => 'GB', 'gb' => 'GB',
|
||||
'德国' => 'DE', 'germany' => 'DE', 'de' => 'DE',
|
||||
'法国' => 'FR', 'france' => 'FR', 'fr' => 'FR',
|
||||
'俄罗斯' => 'RU', 'russia' => 'RU', 'ru' => 'RU',
|
||||
'澳大利亚' => 'AU', 'australia' => 'AU', 'au' => 'AU',
|
||||
'加拿大' => 'CA', 'canada' => 'CA', 'ca' => 'CA',
|
||||
'印度' => 'IN', 'india' => 'IN', 'in' => 'IN',
|
||||
'越南' => 'VN', 'vietnam' => 'VN', 'vn' => 'VN',
|
||||
'泰国' => 'TH', 'thailand' => 'TH', 'th' => 'TH',
|
||||
'马来西亚' => 'MY', 'malaysia' => 'MY', 'my' => 'MY',
|
||||
'菲律宾' => 'PH', 'philippines' => 'PH', 'ph' => 'PH',
|
||||
'印度尼西亚' => 'ID', 'indonesia' => 'ID', 'id' => 'ID',
|
||||
];
|
||||
$key = function_exists('mb_strtolower') ? mb_strtolower($raw, 'UTF-8') : strtolower($raw);
|
||||
if (isset($map[$raw])) {
|
||||
return $map[$raw];
|
||||
}
|
||||
if (isset($map[$key])) {
|
||||
return $map[$key];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,7 +205,16 @@ trait ModerationPart2 {
|
||||
// ============================================================
|
||||
|
||||
private function get_client_ip(): string {
|
||||
$keys = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
|
||||
if (function_exists('aksrt_get_client_ip')) {
|
||||
return (string) aksrt_get_client_ip();
|
||||
}
|
||||
$keys = [
|
||||
'HTTP_CF_CONNECTING_IP',
|
||||
'HTTP_TRUE_CLIENT_IP',
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_REAL_IP',
|
||||
'HTTP_CLIENT_IP',
|
||||
];
|
||||
foreach ($keys as $key) {
|
||||
if (!empty($_SERVER[$key])) {
|
||||
$parts = explode(',', (string) $_SERVER[$key]);
|
||||
|
||||
@@ -292,7 +292,10 @@ class CommentActions {
|
||||
'comment_content' => $content,
|
||||
'comment_parent' => $parent,
|
||||
'comment_type' => '',
|
||||
'comment_author_IP' => isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash((string) $_SERVER['REMOTE_ADDR'])) : '',
|
||||
// Prefer real client IP behind CDN/proxy (REMOTE_ADDR alone is often edge IP).
|
||||
'comment_author_IP' => function_exists('aksrt_get_client_ip')
|
||||
? (string) aksrt_get_client_ip()
|
||||
: (isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash((string) $_SERVER['REMOTE_ADDR'])) : ''),
|
||||
'comment_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash((string) $_SERVER['HTTP_USER_AGENT'])) : '',
|
||||
'comment_date' => current_time('mysql'),
|
||||
'comment_date_gmt' => current_time('mysql', true),
|
||||
|
||||
@@ -127,14 +127,19 @@ class APIAudit {
|
||||
* Get client IP
|
||||
*/
|
||||
private function get_client_ip(): string {
|
||||
if (function_exists('aksrt_get_client_ip')) {
|
||||
return sanitize_text_field((string) aksrt_get_client_ip());
|
||||
}
|
||||
$ip = '';
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
$parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||
if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
|
||||
$ip = (string) $_SERVER['HTTP_CF_CONNECTING_IP'];
|
||||
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
$parts = explode(',', (string) $_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||
$ip = trim($parts[0]);
|
||||
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
||||
$ip = $_SERVER['HTTP_X_REAL_IP'];
|
||||
$ip = (string) $_SERVER['HTTP_X_REAL_IP'];
|
||||
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||
$ip = $_SERVER['REMOTE_ADDR'];
|
||||
$ip = (string) $_SERVER['REMOTE_ADDR'];
|
||||
}
|
||||
return sanitize_text_field($ip);
|
||||
}
|
||||
|
||||
@@ -45,19 +45,31 @@ class IPGeo {
|
||||
|
||||
$cache_key = 'aksrt_ip_geo_' . md5($ip);
|
||||
$ttl = max(3600, (int) ($config['ip_geo_cache_ttl'] ?? 86400));
|
||||
// Negative cache: avoid hammering providers on repeated failures (admin force skips).
|
||||
$neg_key = 'aksrt_ip_geo_neg_' . md5($ip);
|
||||
$neg_ttl = 600;
|
||||
|
||||
if (!$force) {
|
||||
$cached = get_transient($cache_key);
|
||||
if ($cached !== false && is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
$neg = get_transient($neg_key);
|
||||
if ($neg !== false && is_array($neg)) {
|
||||
return $neg;
|
||||
}
|
||||
}
|
||||
|
||||
$provider = sanitize_key((string) ($config['ip_addr_sdk'] ?? 'null'));
|
||||
$result = $this->query_provider($provider, $ip, $config);
|
||||
|
||||
if ($this->has_location($result)) {
|
||||
// Strip bulky raw payloads before caching.
|
||||
$store = $result;
|
||||
unset($store['raw']);
|
||||
if (!$force) {
|
||||
set_transient($cache_key, $result, $ttl);
|
||||
set_transient($cache_key, $store, $ttl);
|
||||
delete_transient($neg_key);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
@@ -66,6 +78,13 @@ class IPGeo {
|
||||
$result['reason'] = 'provider_empty';
|
||||
}
|
||||
|
||||
// Cache miss with reason so front-end doesn't retry every page view.
|
||||
if (!$force) {
|
||||
$store = $result;
|
||||
unset($store['raw']);
|
||||
set_transient($neg_key, $store, $neg_ttl);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -85,11 +104,16 @@ class IPGeo {
|
||||
$missing[] = 'ip_addr_sdk_amap_appkey';
|
||||
}
|
||||
}
|
||||
// polling / null (free): no hard credential requirements.
|
||||
|
||||
// Free + polling are always "configured" once enabled; paid need keys.
|
||||
// verified flips after a successful admin test (or any write of ip_geo_verified).
|
||||
$configured = $enabled && empty($missing);
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'provider' => $provider,
|
||||
'configured' => $enabled && empty($missing),
|
||||
'configured' => $configured,
|
||||
'available' => $enabled,
|
||||
'verified' => !empty($config['ip_geo_verified']),
|
||||
'transport' => 'http_api',
|
||||
@@ -110,11 +134,19 @@ class IPGeo {
|
||||
$province = $this->string_field($geo['province'] ?? '');
|
||||
$city = $this->string_field($geo['city'] ?? '');
|
||||
|
||||
if ($province !== '' && $city !== '' && $province === $city) {
|
||||
$city = '';
|
||||
}
|
||||
|
||||
switch ($format) {
|
||||
case 'province':
|
||||
return $province !== '' ? $province : $city;
|
||||
if ($province !== '') {
|
||||
return $province;
|
||||
}
|
||||
return $city !== '' ? $city : $country;
|
||||
case 'country_province':
|
||||
return trim($country . ' ' . $province);
|
||||
$joined = trim($country . ' ' . $province);
|
||||
return $joined !== '' ? $joined : $city;
|
||||
case 'province_city':
|
||||
default:
|
||||
$parts = array_filter([$province, $city]);
|
||||
@@ -255,6 +287,19 @@ class IPGeo {
|
||||
]);
|
||||
}
|
||||
|
||||
// Amap IP API is IPv4-only; free path for IPv6.
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||
$free = $this->query_ip_api($ip);
|
||||
if ($this->has_location($free)) {
|
||||
$free['detail'] = 'amap_ipv6_fallback_ip_api';
|
||||
return $free;
|
||||
}
|
||||
return $this->empty_result('provider_empty', [
|
||||
'provider' => 'amap',
|
||||
'detail' => 'amap_ipv4_only',
|
||||
]);
|
||||
}
|
||||
|
||||
// Amap sign: md5(sorted query without sig + secret) — zibll uses ip=&key= order.
|
||||
$base_query = 'ip=' . $ip . '&key=' . $appkey;
|
||||
$url = 'https://restapi.amap.com/v3/ip?' . $base_query;
|
||||
@@ -374,6 +419,13 @@ class IPGeo {
|
||||
$country = $this->string_field($addr[1] ?? '');
|
||||
|
||||
if ($province === '' && $city_s === '') {
|
||||
// HTTP OK but unusable body (block page / CAPTCHA / empty) → free path.
|
||||
$fallback = $this->query_ip_api($ip);
|
||||
if ($this->has_location($fallback)) {
|
||||
$fallback['provider'] = 'ip-api';
|
||||
$fallback['detail'] = 'pconline_parse_failed_fallback_ip_api';
|
||||
return $fallback;
|
||||
}
|
||||
return $this->empty_result('provider_empty', [
|
||||
'provider' => 'pconline',
|
||||
'detail' => 'parse_failed',
|
||||
|
||||
@@ -53,19 +53,20 @@ class ExtensionRuntime {
|
||||
$provider = 'null';
|
||||
}
|
||||
|
||||
$display_raw = $settings['ip_geo_display'] ?? ['comment', 'user_home', 'user_center', 'post'];
|
||||
$display = is_array($display_raw) ? $display_raw : [];
|
||||
$allowed_display = ['comment', 'user_home', 'user_center', 'post'];
|
||||
$display_norm = [];
|
||||
foreach ($display as $item) {
|
||||
$key = sanitize_key((string) $item);
|
||||
if (in_array($key, $allowed_display, true) && !in_array($key, $display_norm, true)) {
|
||||
$display_norm[] = $key;
|
||||
}
|
||||
}
|
||||
// Empty / missing → full four surfaces (canonical default).
|
||||
if ($display_norm === []) {
|
||||
// Only fill defaults when the key was never saved — empty array means "none".
|
||||
if (!array_key_exists('ip_geo_display', $settings)) {
|
||||
$display_norm = $allowed_display;
|
||||
} else {
|
||||
$display_raw = $settings['ip_geo_display'];
|
||||
$display = is_array($display_raw) ? $display_raw : [];
|
||||
$display_norm = [];
|
||||
foreach ($display as $item) {
|
||||
$key = sanitize_key((string) $item);
|
||||
if (in_array($key, $allowed_display, true) && !in_array($key, $display_norm, true)) {
|
||||
$display_norm[] = $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$format = sanitize_key((string) ($settings['ip_geo_format'] ?? 'province_city'));
|
||||
|
||||
Reference in New Issue
Block a user