"""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: _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