UMBRA/backend/app/routers/weather.py
Kyle Pope 374e07708f Fix QA review issues: route path, blocking I/O, API key leak, cache
- CRIT-1: Change weather route from /weather to / (was doubling prefix)
- CRIT-2: Use run_in_executor for urllib calls + parallel fetch
- WARN-1: Invalidate weather cache when city changes
- WARN-2: Sanitize error messages to prevent API key leakage
- SUG-2: Only enable weather query when city is configured
- SUG-4: Remove duplicate Bell import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:36:06 +08:00

87 lines
3.0 KiB
Python

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime, timedelta
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.config import settings as app_settings
from app.routers.auth import get_current_session
router = APIRouter()
_cache: dict = {}
def _fetch_json(url: str) -> dict:
with urllib.request.urlopen(url, timeout=10) as resp:
return json.loads(resp.read().decode())
@router.get("/")
async def get_weather(
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
# Get city from settings
result = await db.execute(select(Settings))
settings_row = result.scalar_one_or_none()
city = settings_row.weather_city if settings_row else None
if not city:
raise HTTPException(status_code=400, detail="No weather city configured")
# Check cache (also invalidate if city changed)
now = datetime.now()
if _cache.get("expires_at") and now < _cache["expires_at"] and _cache.get("city") == city:
return _cache["data"]
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_event_loop()
# Current weather + forecast in parallel via thread pool
current_url = f"https://api.openweathermap.org/data/2.5/weather?q={urllib.parse.quote(city)}&units=metric&appid={api_key}"
forecast_url = f"https://api.openweathermap.org/data/2.5/forecast?q={urllib.parse.quote(city)}&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 for 1 hour
_cache["data"] = weather_result
_cache["expires_at"] = now + timedelta(hours=1)
_cache["city"] = city
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")