- New User model (username, argon2id password_hash, totp fields, lockout) - New UserSession model (DB-backed revocation, replaces in-memory set) - New services/auth.py: Argon2id hashing, bcrypt→Argon2id upgrade path, URLSafeTimedSerializer session/MFA tokens - New schemas/auth.py: SetupRequest, LoginRequest, ChangePasswordRequest with OWASP password strength validation - Full rewrite of routers/auth.py: setup/login/logout/status/change-password with account lockout (10 failures → 30-min, HTTP 423), IP rate limiting retained as outer layer, get_current_user + get_current_settings dependencies replacing get_current_session - Settings model: drop pin_hash, add user_id FK (nullable for migration) - Schemas/settings.py: remove SettingsCreate, ChangePinRequest, _validate_pin_length - Settings router: rewrite to use get_current_user + get_current_settings, preserve ntfy test endpoint - All 11 consumer routers updated: auth-gate-only routers use get_current_user, routers reading Settings fields use get_current_settings - config.py: add SESSION_MAX_AGE_DAYS, MFA_TOKEN_MAX_AGE_SECONDS, TOTP_ISSUER - main.py: import User and UserSession models for Alembic discovery - requirements.txt: add argon2-cffi>=23.1.0 - Migration 023: create users + user_sessions tables, migrate pin_hash → User row (admin), backfill settings.user_id, drop pin_hash Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
136 lines
4.6 KiB
Python
136 lines
4.6 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
|
|
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_user, get_current_settings
|
|
from app.models.user import User
|
|
|
|
router = APIRouter()
|
|
|
|
_cache: dict = {}
|
|
|
|
|
|
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: Settings = Depends(get_current_settings)
|
|
):
|
|
city = current_user.weather_city
|
|
lat = current_user.weather_lat
|
|
lon = current_user.weather_lon
|
|
|
|
if not city and (lat is None or lon is None):
|
|
raise HTTPException(status_code=400, detail="No weather location configured")
|
|
|
|
# Build cache key from coordinates or city
|
|
use_coords = lat is not None and lon is not None
|
|
cache_key = f"{lat},{lon}" if use_coords else city
|
|
|
|
# Check cache
|
|
now = datetime.now()
|
|
if _cache.get("expires_at") and now < _cache["expires_at"] and _cache.get("cache_key") == cache_key:
|
|
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_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 for 1 hour
|
|
_cache["data"] = weather_result
|
|
_cache["expires_at"] = now + timedelta(hours=1)
|
|
_cache["cache_key"] = cache_key
|
|
|
|
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")
|