Merge pull request 'fix(ip-geo): harden full-chain IP geolocation reliability' (#313) from worktree-ip-geo-fix into main

Reviewed-on: CYL/wordpress-theme-aksrt#313
This commit is contained in:
2026-07-27 16:10:05 +08:00
12 changed files with 256 additions and 54 deletions
@@ -172,9 +172,21 @@ function matchDependsOn(dependsOn) {
return !!props.formData[dependsOn]
}
if (typeof dependsOn === 'object') {
const { key, value, not } = dependsOn
const { key, value, values, not } = dependsOn
const currentVal = props.formData[key]
if (not) return currentVal !== value
if (not) {
if (Array.isArray(values)) {
// eslint-disable-next-line eqeqeq
return !values.some((v) => currentVal == v)
}
// eslint-disable-next-line eqeqeq
return currentVal !== value && currentVal != value
}
// values: match any of a list (e.g. provider ∈ [qq, polling])
if (Array.isArray(values)) {
// eslint-disable-next-line eqeqeq
return values.some((v) => currentVal == v)
}
// Loose equality so true matches 1 / '1' from some option stores.
// eslint-disable-next-line eqeqeq
return currentVal == value || (!!value === true && !!currentVal)
@@ -44,10 +44,10 @@ export const ipSchema = {
title: '接口凭据',
icon: 'fas fa-key',
fields: [
{ key: 'ip_addr_sdk_qq_appkey', label: '腾讯 AppKey', type: 'input', default: '', dependsOn: { key: 'ip_addr_sdk', value: 'qq' } },
{ key: 'ip_addr_sdk_qq_secretkey', label: '腾讯 SecretKey', type: 'input', default: '', dependsOn: { key: 'ip_addr_sdk', value: 'qq' } },
{ key: 'ip_addr_sdk_amap_appkey', label: '高德 AppKey', type: 'input', default: '', dependsOn: { key: 'ip_addr_sdk', value: 'amap' }, help: '必须在高德开放平台创建「Web服务」类型 Key,并开通 IP 定位。选错平台会报 USERKEY_PLAT_NOMATCH。' },
{ key: 'ip_addr_sdk_amap_secretkey', label: '高德 SecretKey', type: 'input', default: '', dependsOn: { key: 'ip_addr_sdk', value: 'amap' }, help: '若控制台启用了「数字签名」,必填安全密钥;未启用可留空。' },
{ key: 'ip_addr_sdk_qq_appkey', label: '腾讯 AppKey', type: 'input', default: '', dependsOn: { key: 'ip_addr_sdk', values: ['qq', 'polling'] }, help: '「腾讯位置服务」或「轮流查询」时填写;未填则轮询会跳过腾讯。' },
{ key: 'ip_addr_sdk_qq_secretkey', label: '腾讯 SecretKey', type: 'input', default: '', dependsOn: { key: 'ip_addr_sdk', values: ['qq', 'polling'] }, help: '启用 SK 签名时必填;未启用可留空。' },
{ key: 'ip_addr_sdk_amap_appkey', label: '高德 AppKey', type: 'input', default: '', dependsOn: { key: 'ip_addr_sdk', values: ['amap', 'polling'] }, help: '必须在高德开放平台创建「Web服务」类型 Key,并开通 IP 定位。选错平台会报 USERKEY_PLAT_NOMATCH。「轮流查询」时未填则跳过高德。' },
{ key: 'ip_addr_sdk_amap_secretkey', label: '高德 SecretKey', type: 'input', default: '', dependsOn: { key: 'ip_addr_sdk', values: ['amap', 'polling'] }, help: '若控制台启用了「数字签名」,必填安全密钥;未启用可留空。' },
],
},
{
+22 -6
View File
@@ -151,18 +151,34 @@ if (!function_exists('aksrt_format_geo_label')) :
return $label;
}
}
$country = $addr['country'];
$province = $addr['province'];
$city = $addr['city'];
// Drop duplicated province+city when provider returns the same token twice.
if ($province !== '' && $city !== '' && $province === $city) {
$city = '';
}
switch ($type) {
case 'province':
return $addr['province'] !== '' ? $addr['province'] : $addr['city'];
if ($province !== '') {
return $province;
}
return $city !== '' ? $city : $country;
case 'country_province':
return trim($addr['country'] . ' ' . $addr['province']);
$joined = trim($country . ' ' . $province);
if ($joined !== '') {
return $joined;
}
return $city;
case 'province_city':
default:
$parts = array_filter([$addr['province'], $addr['city']]);
if (empty($parts) && $addr['country'] !== '') {
return $addr['country'];
$parts = array_filter([$province, $city]);
if (!empty($parts)) {
return implode(' ', $parts);
}
return implode(' ', $parts);
return $country;
}
}
endif;
+15
View File
@@ -50,6 +50,21 @@ if (!function_exists('aksrt_store_comment_addr')) :
$ip = $comment instanceof WP_Comment ? (string) $comment->comment_author_IP : '';
}
$ip = trim((string) $ip);
// Private / empty stored IP (CDN edge without proxy headers): resolve live client IP once.
$needs_live = ($ip === '' || $ip === '0.0.0.0');
if (!$needs_live && function_exists('filter_var')) {
$needs_live = !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
}
if ($needs_live && function_exists('aksrt_get_client_ip')) {
$live = trim((string) aksrt_get_client_ip());
if ($live !== '' && $live !== '0.0.0.0'
&& filter_var($live, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)
) {
$ip = $live;
}
}
if ($ip === '') {
return;
}
+29 -4
View File
@@ -218,11 +218,29 @@ function aksrt_get_captcha_status_summary(): array {
// ============================================================
function aksrt_get_client_ip(): string {
$keys = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
// Prefer CDN / reverse-proxy headers (Cloudflare first), then generic proxies.
$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]);
$ip = trim($parts[0]);
if (empty($_SERVER[$key])) {
continue;
}
// X-Forwarded-For may be a chain: client, proxy1, proxy2 — take first valid.
$parts = explode(',', (string) $_SERVER[$key]);
foreach ($parts as $part) {
$ip = trim($part);
// Strip IPv6 brackets / port noise occasionally injected by proxies.
if (str_starts_with($ip, '[') && str_contains($ip, ']')) {
$ip = substr($ip, 1, strpos($ip, ']') - 1);
} elseif (substr_count($ip, ':') === 1 && str_contains($ip, '.')) {
// IPv4:port
$ip = explode(':', $ip, 2)[0];
}
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
@@ -231,6 +249,13 @@ function aksrt_get_client_ip(): string {
return isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '0.0.0.0';
}
// Native wp-comments-post + any path that omits comment_author_IP.
// Ensures comment_author_IP (and thus aksrt_comment_addr) is the real client IP behind CDN.
add_filter('pre_comment_user_ip', static function ($ip) {
$resolved = aksrt_get_client_ip();
return ($resolved !== '' && $resolved !== '0.0.0.0') ? $resolved : $ip;
}, 5);
// ============================================================
// 腾讯云 API 3.0 TC3-HMAC-SHA256 签名
// ============================================================
+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'));