Spaces:
Running
Running
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 | """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 | |