UMBRA/backend/app/routers/weather.py
Kyle Pope d8bdae8ec3 Implement multi-user RBAC: database, auth, routing, admin API (Phases 1-6)
Phase 1: Add role, mfa_enforce_pending, must_change_password to users table.
Create system_config (singleton) and audit_log tables. Migration 026.

Phase 2: Add user_id FK to all 8 data tables (todos, reminders, projects,
calendars, people, locations, event_templates, ntfy_sent) with 4-step
nullable→backfill→FK→NOT NULL pattern. Migrations 027-034.

Phase 3: Harden auth schemas (extra="forbid" on RegisterRequest), add
MFA enforcement token serializer with distinct salt, rewrite auth router
with require_role() factory and registration endpoint.

Phase 4: Scope all 12 routers by user_id, fix dependency type bugs,
bound weather cache (SEC-15), multi-user ntfy dispatch.

Phase 5: Create admin router (14 endpoints), admin schemas, audit
service, rate limiting in nginx. SEC-08 CSRF via X-Requested-With.

Phase 6: Update frontend types, useAuth hook (role/isAdmin/register),
App.tsx (AdminRoute guard), Sidebar (admin link), api.ts (XHR header).

Security findings addressed: SEC-01, SEC-02, SEC-03, SEC-04, SEC-05,
SEC-06, SEC-07, SEC-08, SEC-12, SEC-13, SEC-15.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:06:25 +08:00

158 lines
5.5 KiB
Python

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")