from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from datetime import datetime, timedelta from collections import OrderedDict import asyncio import urllib.request import urllib.parse import urllib.error import json from app.database import get_db from app.models.settings import Settings from app.models.user import User from app.config import settings as app_settings from app.routers.auth import get_current_user, get_current_settings router = APIRouter() # SEC-15: Bounded LRU cache keyed by (user_id, location) — max 100 entries. # OrderedDict preserves insertion order; move_to_end on hit, popitem(last=False) # to evict the oldest when capacity is exceeded. _CACHE_MAX = 100 _cache: OrderedDict = OrderedDict() def _cache_get(key: tuple) -> dict | None: """Return cached entry if it exists and hasn't expired.""" entry = _cache.get(key) if entry and datetime.now() < entry["expires_at"]: _cache.move_to_end(key) # LRU: promote to most-recently-used return entry["data"] if entry: del _cache[key] # expired — evict immediately return None def _cache_set(key: tuple, data: dict) -> None: """Store an entry; evict the oldest if over capacity.""" if key in _cache: _cache.move_to_end(key) _cache[key] = {"data": data, "expires_at": datetime.now() + timedelta(hours=1)} while len(_cache) > _CACHE_MAX: _cache.popitem(last=False) # evict LRU (oldest) class GeoSearchResult(BaseModel): name: str state: str country: str lat: float lon: float def _fetch_json(url: str) -> dict: with urllib.request.urlopen(url, timeout=10) as resp: return json.loads(resp.read().decode()) @router.get("/search", response_model=list[GeoSearchResult]) async def search_locations( q: str = Query(..., min_length=1, max_length=100), current_user: User = Depends(get_current_user) ): api_key = app_settings.OPENWEATHERMAP_API_KEY if not api_key: raise HTTPException(status_code=500, detail="Weather API key not configured") try: loop = asyncio.get_running_loop() url = f"https://api.openweathermap.org/geo/1.0/direct?q={urllib.parse.quote(q)}&limit=5&appid={api_key}" results = await loop.run_in_executor(None, _fetch_json, url) return [ { "name": r.get("name", ""), "state": r.get("state", ""), "country": r.get("country", ""), "lat": r.get("lat"), "lon": r.get("lon"), } for r in results ] except urllib.error.URLError: raise HTTPException(status_code=502, detail="Geocoding service unavailable") except (KeyError, json.JSONDecodeError): raise HTTPException(status_code=502, detail="Invalid geocoding data") @router.get("/") async def get_weather( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), current_settings: Settings = Depends(get_current_settings), ): city = current_settings.weather_city lat = current_settings.weather_lat lon = current_settings.weather_lon if not city and (lat is None or lon is None): raise HTTPException(status_code=400, detail="No weather location configured") # Cache key includes user_id so each user gets isolated cache entries use_coords = lat is not None and lon is not None location_key = f"{lat},{lon}" if use_coords else city cache_key = (current_user.id, location_key) cached = _cache_get(cache_key) if cached is not None: return cached api_key = app_settings.OPENWEATHERMAP_API_KEY if not api_key: raise HTTPException(status_code=500, detail="Weather API key not configured") try: loop = asyncio.get_running_loop() # Build query params based on coordinates or city name if use_coords: location_params = f"lat={lat}&lon={lon}" else: location_params = f"q={urllib.parse.quote(city)}" current_url = f"https://api.openweathermap.org/data/2.5/weather?{location_params}&units=metric&appid={api_key}" forecast_url = f"https://api.openweathermap.org/data/2.5/forecast?{location_params}&units=metric&cnt=8&appid={api_key}" current_data, forecast_data = await asyncio.gather( loop.run_in_executor(None, _fetch_json, current_url), loop.run_in_executor(None, _fetch_json, forecast_url), ) rain_chance = 0 for item in forecast_data.get("list", []): pop = item.get("pop", 0) if pop > rain_chance: rain_chance = pop weather_result = { "temp": round(current_data["main"]["temp"]), "temp_min": round(current_data["main"]["temp_min"]), "temp_max": round(current_data["main"]["temp_max"]), "description": current_data["weather"][0]["description"], "rain_chance": round(rain_chance * 100), "sunrise": current_data["sys"]["sunrise"], "sunset": current_data["sys"]["sunset"], "city": current_data["name"], } _cache_set(cache_key, weather_result) return weather_result except urllib.error.URLError: raise HTTPException(status_code=502, detail="Weather service unavailable") except (KeyError, json.JSONDecodeError): raise HTTPException(status_code=502, detail="Invalid weather data")