AuthorBot
fix: geo lookup now works on HF Spaces without MaxMind DB - Add ip-api.com async HTTP fallback when GeoLite2 mmdb is not present - 3-tier resolution: Redis cache (24h) -> MaxMind (offline) -> ip-api.com (free, no key) - Cache key uses SHA-256 hash of IP (raw IP never stored) - Pass redis into get_geo_info from both chat.py and visitor_tracker.py - Fixes country showing as dash on all sessions
f65c89c
Raw
History Blame Contribute Delete
6.09 kB
"""Author RAG Chatbot SaaS β€” GeoIP Lookup.
IP β†’ country/region/city mapping.
Resolution order (first success wins):
1. MaxMind GeoLite2-City.mmdb (offline, fast, requires downloaded DB file)
2. ip-api.com free tier (HTTP, 45 req/min, no API key, results cached 24h in Redis)
IP address is NEVER stored β€” only geo fields derived from it.
Redis cache key is a SHA-256 hash of the IP (not the raw IP).
"""
import hashlib
import structlog
logger = structlog.get_logger(__name__)
_GEO_CACHE_TTL = 86_400 # 24 hours
_GEO_CACHE_PREFIX = "geo:ip:" # key = geo:ip:<sha256[:16]>
_geoip_reader = None
def _get_reader():
"""Lazily load and cache the MaxMind GeoLite2 reader."""
global _geoip_reader
if _geoip_reader is None:
try:
import maxminddb
from app.config import get_settings
cfg = get_settings()
_geoip_reader = maxminddb.open_database(cfg.GEO_DB_PATH)
logger.info("MaxMind GeoIP DB loaded", path=cfg.GEO_DB_PATH)
except Exception as e:
logger.warning("MaxMind GeoIP DB not available β€” will use ip-api.com fallback", error=str(e))
return _geoip_reader
def _ip_cache_key(ip: str) -> str:
"""Return a Redis key for a given IP β€” uses a hash, never the raw IP."""
hashed = hashlib.sha256(ip.encode()).hexdigest()[:16]
return f"{_GEO_CACHE_PREFIX}{hashed}"
async def _lookup_maxmind(ip: str) -> dict | None:
"""Try MaxMind GeoLite2-City.mmdb lookup. Returns None if DB unavailable."""
reader = _get_reader()
if reader is None:
return None
try:
record = reader.get(ip)
if not record:
return None
country = record.get("country", {})
city = record.get("city", {})
subdivisions = record.get("subdivisions", [])
region = ""
if subdivisions:
region = (subdivisions[0].get("names") or {}).get("en", "")
return {
"country_code": country.get("iso_code", "")[:2],
"country_name": (country.get("names") or {}).get("en", ""),
"region": region,
"city": (city.get("names") or {}).get("en", ""),
}
except Exception as e:
logger.debug("MaxMind lookup failed", error=str(e))
return None
async def _lookup_ipapi(ip: str) -> dict:
"""Fallback: query ip-api.com free endpoint (no API key, 45 req/min).
Returns empty dict on any error so caller always gets a safe result.
Fields requested: country code, country name, region, city only.
"""
try:
import httpx
url = f"http://ip-api.com/json/{ip}?fields=status,country,countryCode,regionName,city"
async with httpx.AsyncClient(timeout=3.0) as client:
resp = await client.get(url)
if resp.status_code != 200:
return {}
data = resp.json()
if data.get("status") != "success":
return {}
return {
"country_code": (data.get("countryCode") or "")[:2],
"country_name": data.get("country") or "",
"region": data.get("regionName") or "",
"city": data.get("city") or "",
}
except Exception as e:
logger.debug("ip-api.com fallback failed", error=str(e))
return {}
async def get_geo_info(request, redis=None) -> dict:
"""Derive country, region, and city from the request IP.
Resolution order:
1. Redis cache (24h TTL, keyed by hashed IP)
2. MaxMind GeoLite2 (if DB file present)
3. ip-api.com free API (fallback)
Args:
request: FastAPI request.
redis: Optional Redis client for caching ip-api results.
Returns:
Dict with country_code, country_name, region, city.
Returns empty dict if all lookups fail.
"""
ip = get_real_ip(request)
if not ip or ip in ("127.0.0.1", "::1", "unknown"):
return {}
cache_key = _ip_cache_key(ip)
# ── 1. Check Redis cache ──────────────────────────────────────────────────
if redis is not None:
try:
import json
cached = await redis.get(cache_key)
if cached:
return json.loads(cached)
except Exception:
pass # Cache miss β€” proceed to lookup
# ── 2. MaxMind (fast, offline) ────────────────────────────────────────────
result = await _lookup_maxmind(ip)
# ── 3. ip-api.com fallback (free, no API key) ─────────────────────────────
if result is None:
result = await _lookup_ipapi(ip)
if not result:
return {}
# ── Cache the result to avoid repeated HTTP calls ─────────────────────────
if redis is not None:
try:
import json
await redis.setex(cache_key, _GEO_CACHE_TTL, json.dumps(result))
except Exception:
pass
return result
def get_real_ip(request) -> str:
"""Extract real client IP from request, respecting proxy headers.
Checks X-Forwarded-For first (set by nginx/load balancers), then
X-Real-IP, then falls back to the direct connection host.
Args:
request: FastAPI request.
Returns:
IP string, or 'unknown' if none can be determined.
"""
# X-Forwarded-For: client, proxy1, proxy2 β€” leftmost is the real client
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
return forwarded.split(",")[0].strip()
# X-Real-IP: set by some nginx configs as a single IP
real_ip = request.headers.get("X-Real-IP", "")
if real_ip:
return real_ip.strip()
return request.client.host if request.client else "unknown"
# Backward-compatible alias
_get_real_ip = get_real_ip