324 lines
11 KiB
Python
324 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from collections import defaultdict
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import FastAPI, HTTPException, Query, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.cache import close_redis, init_redis
|
|
from app.config import (
|
|
APP_NAME,
|
|
APP_VERSION,
|
|
DEFAULT_LAT,
|
|
DEFAULT_LON,
|
|
DEFAULT_PLACE,
|
|
DWD_WMS_URL,
|
|
PRECIP_MAP_HOURS_DEFAULT,
|
|
PRECIP_MAP_SPAN_DEG,
|
|
PRECIP_MAP_STEP_DEG,
|
|
RADAR_PRODUCT,
|
|
RATE_LIMIT_PER_MINUTE,
|
|
RAW_RADAR_DIR,
|
|
RENDERED_RADAR_DIR,
|
|
)
|
|
from app.database import close_db, init_db
|
|
from app.http_client import close_http_client, init_http_client
|
|
from app.services.dashboard import build_dashboard
|
|
from app.services.dwd_radar import fetch_radar_index
|
|
from app.services.geocoding import reverse_geocode, search_places
|
|
from app.routes.admin import router as admin_router
|
|
from app.security import (
|
|
cors_allowed_origins,
|
|
is_api_key_exempt,
|
|
trusted_hosts,
|
|
validate_security_config,
|
|
verify_request_api_key,
|
|
)
|
|
from app.services.precipitation_map import get_precipitation_map
|
|
from app.services.warnings import fetch_warnings_for_location
|
|
from app.services.weather import get_forecast, get_observations
|
|
from app.services.wms import fetch_wms_time_steps, fetch_wms_time_steps_quick
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_rate_buckets: dict[str, list[float]] = defaultdict(list)
|
|
|
|
|
|
class Location(BaseModel):
|
|
lat: float = DEFAULT_LAT
|
|
lon: float = DEFAULT_LON
|
|
place: str = DEFAULT_PLACE
|
|
|
|
|
|
class FavoriteLocation(BaseModel):
|
|
place: str
|
|
lat: float = Field(ge=-90, le=90)
|
|
lon: float = Field(ge=-180, le=180)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_: FastAPI):
|
|
validate_security_config()
|
|
await init_http_client()
|
|
await init_db()
|
|
await init_redis()
|
|
RENDERED_RADAR_DIR.mkdir(parents=True, exist_ok=True)
|
|
RAW_RADAR_DIR.mkdir(parents=True, exist_ok=True)
|
|
yield
|
|
await close_redis()
|
|
await close_db()
|
|
await close_http_client()
|
|
|
|
|
|
app = FastAPI(title=APP_NAME, version=APP_VERSION, lifespan=lifespan)
|
|
app.include_router(admin_router)
|
|
|
|
_hosts = list(trusted_hosts())
|
|
if _hosts:
|
|
app.add_middleware(TrustedHostMiddleware, allowed_hosts=_hosts)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=cors_allowed_origins(),
|
|
allow_methods=["GET", "POST", "PUT", "OPTIONS"],
|
|
allow_headers=["Authorization", "Content-Type", "X-API-Key"],
|
|
)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def api_key_middleware(request: Request, call_next):
|
|
path = request.url.path
|
|
if path.startswith("/api/") and not is_api_key_exempt(path):
|
|
if not verify_request_api_key(request):
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={"detail": "Invalid or missing API key"},
|
|
headers={"WWW-Authenticate": "ApiKey"},
|
|
)
|
|
return await call_next(request)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def security_headers_middleware(request: Request, call_next):
|
|
response = await call_next(request)
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["X-Frame-Options"] = "DENY"
|
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
response.headers["Permissions-Policy"] = "geolocation=(self)"
|
|
if path := request.url.path:
|
|
if path.startswith("/api/admin") or path.endswith("admin.html"):
|
|
response.headers["X-Robots-Tag"] = "noindex, nofollow"
|
|
return response
|
|
|
|
|
|
@app.middleware("http")
|
|
async def rate_limit_middleware(request: Request, call_next):
|
|
if not request.url.path.startswith("/api/"):
|
|
return await call_next(request)
|
|
client_ip = request.client.host if request.client else "unknown"
|
|
now = time.time()
|
|
limit = 20 if request.url.path == "/api/admin/login" else RATE_LIMIT_PER_MINUTE
|
|
bucket = _rate_buckets[client_ip]
|
|
_rate_buckets[client_ip] = [ts for ts in bucket if now - ts < 60]
|
|
if len(_rate_buckets[client_ip]) >= limit:
|
|
return JSONResponse(status_code=429, content={"detail": "Rate limit exceeded"})
|
|
_rate_buckets[client_ip].append(now)
|
|
return await call_next(request)
|
|
|
|
|
|
def utc_now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
@app.get("/api/health")
|
|
async def health() -> dict[str, Any]:
|
|
return {
|
|
"status": "ok",
|
|
"service": APP_NAME,
|
|
"version": APP_VERSION,
|
|
"time_utc": utc_now().isoformat(),
|
|
}
|
|
|
|
|
|
@app.get("/api/dashboard")
|
|
async def dashboard(
|
|
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
|
|
lon: float = Query(DEFAULT_LON, ge=-180, le=180),
|
|
local_only: bool = Query(True),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return await build_dashboard(lat, lon, local_only=local_only)
|
|
except httpx.HTTPError as exc:
|
|
raise HTTPException(status_code=502, detail=f"Dashboard unavailable: {exc}") from exc
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=502, detail=f"Dashboard error: {exc}") from exc
|
|
|
|
|
|
@app.get("/api/location/default")
|
|
async def default_location() -> Location:
|
|
return Location()
|
|
|
|
|
|
@app.get("/api/geocode/search")
|
|
async def geocode_search(q: str = Query(..., min_length=2), limit: int = Query(8, ge=1, le=15)) -> dict[str, Any]:
|
|
try:
|
|
results = await search_places(q, limit=limit)
|
|
except httpx.HTTPError as exc:
|
|
raise HTTPException(status_code=502, detail=f"Geocoding unavailable: {exc}") from exc
|
|
return {"query": q, "count": len(results), "results": results}
|
|
|
|
|
|
@app.get("/api/geocode/reverse")
|
|
async def geocode_reverse(
|
|
lat: float = Query(..., ge=-90, le=90),
|
|
lon: float = Query(..., ge=-180, le=180),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
result = await reverse_geocode(lat, lon)
|
|
except httpx.HTTPError as exc:
|
|
raise HTTPException(status_code=502, detail=f"Reverse geocoding unavailable: {exc}") from exc
|
|
return {"lat": lat, "lon": lon, "result": result}
|
|
|
|
|
|
@app.get("/api/forecast")
|
|
async def forecast(
|
|
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
|
|
lon: float = Query(DEFAULT_LON, ge=-180, le=180),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return await get_forecast(lat, lon)
|
|
except httpx.HTTPError as exc:
|
|
raise HTTPException(status_code=502, detail=f"Forecast source unavailable: {exc}") from exc
|
|
|
|
|
|
@app.get("/api/forecast/precipitation-map")
|
|
async def forecast_precipitation_map(
|
|
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
|
|
lon: float = Query(DEFAULT_LON, ge=-180, le=180),
|
|
hours: int = Query(PRECIP_MAP_HOURS_DEFAULT, ge=6, le=24),
|
|
span_deg: float = Query(PRECIP_MAP_SPAN_DEG, ge=1.5, le=6.0),
|
|
step_deg: float = Query(PRECIP_MAP_STEP_DEG, ge=0.15, le=0.5),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return await get_precipitation_map(lat, lon, hours=hours, span_deg=span_deg, step_deg=step_deg)
|
|
except httpx.HTTPError as exc:
|
|
raise HTTPException(status_code=502, detail=f"Precipitation map unavailable: {exc}") from exc
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=502, detail=f"Precipitation map error: {exc}") from exc
|
|
|
|
|
|
@app.get("/api/observations")
|
|
async def observations(
|
|
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
|
|
lon: float = Query(DEFAULT_LON, ge=-180, le=180),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return await get_observations(lat, lon)
|
|
except httpx.HTTPError as exc:
|
|
raise HTTPException(status_code=502, detail=f"Observation source unavailable: {exc}") from exc
|
|
|
|
|
|
@app.get("/api/warnings")
|
|
async def warnings(
|
|
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
|
|
lon: float = Query(DEFAULT_LON, ge=-180, le=180),
|
|
local_only: bool = Query(True),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return await fetch_warnings_for_location(lat, lon, local_only=local_only)
|
|
except httpx.HTTPError as exc:
|
|
raise HTTPException(status_code=502, detail=f"Warnings unavailable: {exc}") from exc
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=502, detail=f"Warnings parse error: {exc}") from exc
|
|
|
|
|
|
@app.get("/api/radar/latest")
|
|
async def radar_latest(
|
|
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
|
|
limit: int = Query(12, ge=1, le=100),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return await fetch_radar_index(product=product, limit=limit)
|
|
except httpx.HTTPError as exc:
|
|
raise HTTPException(status_code=502, detail=f"DWD radar index unavailable: {exc}") from exc
|
|
|
|
|
|
@app.get("/api/radar/wms")
|
|
async def radar_wms() -> dict[str, Any]:
|
|
return {
|
|
"service_url": DWD_WMS_URL,
|
|
"attribution": "Radar/Warnungen: Deutscher Wetterdienst (DWD)",
|
|
"default_layer": "dwd:Niederschlagsradar",
|
|
"layers": [
|
|
{
|
|
"id": "niederschlagsradar",
|
|
"title": "Niederschlagsradar",
|
|
"layer": "dwd:Niederschlagsradar",
|
|
"opacity": 0.75,
|
|
"enabled": True,
|
|
"animated": True,
|
|
},
|
|
{
|
|
"id": "warnungen_gemeinden",
|
|
"title": "DWD Warnungen (Gemeinden)",
|
|
"layer": "dwd:Warnungen_Gemeinden_vereinigt",
|
|
"opacity": 0.6,
|
|
"enabled": True,
|
|
"animated": False,
|
|
},
|
|
{
|
|
"id": "radolan_ry",
|
|
"title": "RADOLAN RY",
|
|
"layer": "dwd:RADOLAN-RY",
|
|
"opacity": 0.72,
|
|
"enabled": False,
|
|
"animated": False,
|
|
},
|
|
{
|
|
"id": "radar_wn",
|
|
"title": "Radar WN Reflektivität",
|
|
"layer": "dwd:Radar_wn-product_1x1km_ger",
|
|
"opacity": 0.68,
|
|
"enabled": False,
|
|
"animated": True,
|
|
},
|
|
{
|
|
"id": "warnungen_landkreise",
|
|
"title": "DWD Warnungen Landkreise",
|
|
"layer": "dwd:Warnungen_Landkreise",
|
|
"opacity": 0.55,
|
|
"enabled": False,
|
|
"animated": False,
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
@app.get("/api/radar/wms/times")
|
|
async def radar_wms_times(
|
|
layer: str = Query("dwd:Niederschlagsradar"),
|
|
minutes: int = Query(120, ge=15, le=180),
|
|
quick: bool = Query(False),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
if quick:
|
|
return await fetch_wms_time_steps_quick(layer, minutes=minutes)
|
|
return await fetch_wms_time_steps(layer, minutes=minutes)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=502, detail=f"WMS time dimension unavailable: {exc}") from exc
|
|
|
|
|