Flight-Search / backend /api /geolocation.py
fyliu's picture
Fix geolocation to resolve public IP for localhost/LAN clients
a67b60b
"""Geolocation endpoint — resolves client IP to location + nearest airports."""
from __future__ import annotations
import ipaddress
import math
import httpx
from fastapi import APIRouter, Request
from ..config import COUNTRY_CURRENCY_MAP
from ..data_loader import get_route_graph
router = APIRouter(prefix="/api/geolocation", tags=["geolocation"])
# Fallback for truly unresolvable cases
_FALLBACK = {
"country_code": "US",
"city": "New York",
"lat": 40.7128,
"lon": -74.0060,
"currency": "USD",
}
def _is_private_ip(ip_str: str) -> bool:
"""Check if an IP address is private/loopback/reserved."""
try:
return ipaddress.ip_address(ip_str).is_private
except ValueError:
return True
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Great-circle distance in km between two lat/lon points."""
r = 6371.0
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2
return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def _nearest_airports(lat: float, lon: float, limit: int = 3) -> list[dict]:
"""Find nearest airports by haversine distance."""
graph = get_route_graph()
scored = []
for airport in graph.airports.values():
if airport.latitude == 0.0 and airport.longitude == 0.0:
continue
dist = _haversine_km(lat, lon, airport.latitude, airport.longitude)
scored.append((dist, airport))
scored.sort(key=lambda x: x[0])
return [
{
"iata": a.iata,
"name": a.display_name,
"city": a.city_name,
"distance_km": round(d),
}
for d, a in scored[:limit]
]
async def _resolve_ip(client_ip: str) -> str:
"""If client_ip is private (localhost, LAN), discover the public IP."""
if not _is_private_ip(client_ip):
return client_ip
# Ask ip-api.com without an IP — it returns the server's public IP info
# Or use a lightweight service to get the public IP first
try:
async with httpx.AsyncClient(timeout=3.0) as client:
resp = await client.get("https://api.ipify.org?format=json")
data = resp.json()
return data.get("ip", client_ip)
except Exception:
return client_ip
@router.get("")
async def geolocate(request: Request):
"""Resolve client IP to location, currency, and nearest airports."""
# Get real client IP (behind proxy headers)
client_ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if not client_ip:
client_ip = request.client.host if request.client else "127.0.0.1"
# If IP is private (localhost/LAN), resolve to public IP
lookup_ip = await _resolve_ip(client_ip)
# Try ip-api.com (free, no key, 45 req/min)
try:
async with httpx.AsyncClient(timeout=3.0) as client:
resp = await client.get(f"http://ip-api.com/json/{lookup_ip}?fields=status,country,countryCode,city,lat,lon")
data = resp.json()
if data.get("status") == "success":
country_code = data.get("countryCode", "US")
lat = data.get("lat", _FALLBACK["lat"])
lon = data.get("lon", _FALLBACK["lon"])
city = data.get("city", _FALLBACK["city"])
currency = COUNTRY_CURRENCY_MAP.get(country_code, "USD")
return {
"country_code": country_code,
"city": city,
"lat": lat,
"lon": lon,
"currency": currency,
"nearest_airports": _nearest_airports(lat, lon),
}
except Exception:
pass
# Fallback
return {
**_FALLBACK,
"nearest_airports": _nearest_airports(_FALLBACK["lat"], _FALLBACK["lon"]),
}