from __future__ import annotations import logging import os import re import time from collections import defaultdict from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any import httpx from fastapi import FastAPI, HTTPException, Query, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from pydantic import BaseModel, Field from app.auth import admin_configured from app.cache import close_redis, init_redis, redis_available from app.config import ( APP_NAME, APP_VERSION, BRIGHTSKY_BASE_URL, DEFAULT_LAT, DEFAULT_LON, DEFAULT_PLACE, DWD_RADAR_BASE_URL, DWD_WMS_URL, OPEN_METEO_BASE_URL, RADAR_PRODUCT, RADAR_RETENTION_HOURS, RATE_LIMIT_PER_MINUTE, RENDERED_RADAR_DIR, RAW_RADAR_DIR, ) from app.database import ( close_db, init_db, list_rendered_radar, pool_available, upsert_radar_meta, ) from app.http_client import close_http_client, get_client, init_http_client from app.services.dashboard import build_dashboard from app.services.dwd_radar import RADAR_FILE_RE, fetch_radar_index from app.services.geocoding import reverse_geocode, search_places from app.services.health import check_sources from app.routes.admin import router as admin_router 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 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): 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) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) @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)" 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(), "admin_enabled": admin_configured(), "postgres": pool_available(), "redis": redis_available(), } @app.get("/api/health/sources") async def health_sources() -> dict[str, Any]: sources = await check_sources() degraded = [s for s in sources if s.get("status") not in ("ok", "disabled")] return { "status": "degraded" if degraded else "ok", "time_utc": utc_now().isoformat(), "sources": sources, } @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 def cleanup_old_radar_files() -> None: cutoff = utc_now() - timedelta(hours=RADAR_RETENTION_HOURS) if not RAW_RADAR_DIR.exists(): return for path in RAW_RADAR_DIR.rglob("*"): if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name): continue modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc) if modified < cutoff: path.unlink(missing_ok=True) @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/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.post("/api/radar/sync") async def radar_sync( product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"), limit: int = Query(6, ge=1, le=50), ) -> dict[str, Any]: latest = await radar_latest(product=product, limit=limit) target_dir = RAW_RADAR_DIR / "composite" / product target_dir.mkdir(parents=True, exist_ok=True) downloaded = [] skipped = [] client = get_client() for item in latest["files"]: out = target_dir / item["name"] if out.exists() and out.stat().st_size > 0: skipped.append(item["name"]) continue response = await client.get(item["url"], timeout=90.0) response.raise_for_status() out.write_bytes(response.content) downloaded.append(item["name"]) modified = datetime.fromtimestamp(out.stat().st_mtime, timezone.utc) await upsert_radar_meta(product, item["name"], out.stat().st_size, modified, "pending") cleanup_old_radar_files() return {"product": product, "target_dir": str(target_dir), "downloaded": downloaded, "skipped": skipped} @app.get("/api/radar/files") async def radar_files( product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"), ) -> dict[str, Any]: target_dir = RAW_RADAR_DIR / "composite" / product target_dir.mkdir(parents=True, exist_ok=True) files = [] for path in sorted(target_dir.iterdir()): if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name): continue modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc) files.append( { "name": path.name, "size_bytes": path.stat().st_size, "modified_utc": modified.isoformat(), "download_url": f"/api/radar/files/{product}/{path.name}", } ) return {"product": product, "path": str(target_dir), "count": len(files), "files": files} @app.get("/api/radar/files/{product}/{filename}") async def radar_file_download(product: str, filename: str): if not re.fullmatch(r"[a-z0-9_-]+", product, re.IGNORECASE): raise HTTPException(status_code=400, detail="Invalid product") if not RADAR_FILE_RE.fullmatch(filename): raise HTTPException(status_code=400, detail="Invalid filename") path = RAW_RADAR_DIR / "composite" / product / filename if not path.exists(): raise HTTPException(status_code=404, detail="File not found") media_type = "application/x-tar" if filename.lower().endswith(".tar") else "application/octet-stream" return FileResponse(path, filename=filename, media_type=media_type) @app.get("/api/radar/rendered") async def radar_rendered_list( product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"), limit: int = Query(24, ge=1, le=100), ) -> dict[str, Any]: frames = await list_rendered_radar(product, limit=limit) return {"product": product, "count": len(frames), "frames": frames} @app.get("/api/radar/rendered/{product}/{filename}.png") async def radar_rendered_image(product: str, filename: str): if not re.fullmatch(r"[a-z0-9_.-]+", filename, re.IGNORECASE): raise HTTPException(status_code=400, detail="Invalid filename") path = RENDERED_RADAR_DIR / product / f"{filename}.png" if not path.exists(): raise HTTPException(status_code=404, detail="Rendered image not found") return FileResponse(path, media_type="image/png") @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), ) -> dict[str, Any]: try: 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 @app.get("/api/sources") async def sources() -> dict[str, Any]: return { "note": "HexaWetter kann externe APIs nutzen oder über docker-compose.selfhosted.yml eigene Instanzen anbinden.", "open_meteo_base_url": OPEN_METEO_BASE_URL, "brightsky_base_url": BRIGHTSKY_BASE_URL, "dwd_radar_base_url": DWD_RADAR_BASE_URL, "dwd_wms_url": DWD_WMS_URL, "dwd_attribution": "Datenbasis: Deutscher Wetterdienst (DWD), Open Data.", "osm_attribution": "Kartenbasis: OpenStreetMap-Mitwirkende.", }