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:
2026-07-27 15:39:16 +08:00
parent c206928746
commit 59f5d5d98a
12 changed files with 256 additions and 54 deletions
+13
View File
@@ -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' => '启用数字签名时必填'],
]],
+76 -16
View File
@@ -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]);
+4 -1
View File
@@ -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),
+9 -4
View File
@@ -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);
}
+56 -4
View File
@@ -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',
+12 -11
View File
@@ -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'));