from __future__ import annotations import asyncio import os import re from datetime import datetime, timezone from pathlib import Path from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import FileResponse from pydantic import BaseModel, Field from app.auth import ( ADMIN_USERNAME, admin_configured, check_login_rate_limit, create_access_token, record_failed_login, require_admin, verify_password, ) from app.cache import cache_get, cache_set, redis_available from app.config import ( APP_NAME, APP_VERSION, BRIGHTSKY_BASE_URL, CACHE_TTL_FORECAST, CACHE_TTL_GEOCODE, CACHE_TTL_OBSERVATIONS, CACHE_TTL_WARNINGS, CACHE_TTL_WMS_TIMES, DATA_DIR, DWD_RADAR_BASE_URL, DWD_WMS_URL, OPEN_METEO_BASE_URL, RADAR_PRODUCT, RADAR_RETENTION_HOURS, RATE_LIMIT_PER_MINUTE, RAW_RADAR_DIR, RENDERED_RADAR_DIR, ) from app.database import list_rendered_radar, pool_available from app.services.dwd_radar import RADAR_FILE_RE from app.services.health import check_sources from app.services.radar_sync import sync_radar_files router = APIRouter(prefix="/api/admin", tags=["admin"]) class LoginRequest(BaseModel): username: str = Field(min_length=1, max_length=64) password: str = Field(min_length=8, max_length=128) class SettingsUpdate(BaseModel): rate_limit_per_minute: int | None = Field(default=None, ge=10, le=1000) cache_ttl_forecast: int | None = Field(default=None, ge=60, le=86400) cache_ttl_observations: int | None = Field(default=None, ge=60, le=86400) cache_ttl_warnings: int | None = Field(default=None, ge=30, le=3600) radar_retention_hours: int | None = Field(default=None, ge=6, le=168) def _dir_stats(path: Path) -> dict[str, Any]: if not path.exists(): return {"path": str(path), "exists": False, "files": 0, "bytes": 0} files = [p for p in path.rglob("*") if p.is_file()] total = sum(p.stat().st_size for p in files) return {"path": str(path), "exists": True, "files": len(files), "bytes": total} def _public_config() -> dict[str, Any]: return { "app_name": APP_NAME, "version": APP_VERSION, "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, "radar_product": RADAR_PRODUCT, "radar_retention_hours": RADAR_RETENTION_HOURS, "rate_limit_per_minute": RATE_LIMIT_PER_MINUTE, "cache_ttl": { "forecast": CACHE_TTL_FORECAST, "observations": CACHE_TTL_OBSERVATIONS, "warnings": CACHE_TTL_WARNINGS, "geocode": CACHE_TTL_GEOCODE, "wms_times": CACHE_TTL_WMS_TIMES, }, } @router.get("/status") async def admin_status_public() -> dict[str, Any]: return {"admin_enabled": admin_configured()} @router.post("/login") async def admin_login(request: Request, payload: LoginRequest) -> dict[str, Any]: if not admin_configured(): raise HTTPException(status_code=503, detail="Admin login is not configured on this instance") check_login_rate_limit(request) if payload.username != ADMIN_USERNAME or not verify_password(payload.password, os.getenv("ADMIN_PASSWORD_HASH", "")): record_failed_login(request) await asyncio.sleep(1) raise HTTPException(status_code=401, detail="Invalid credentials") token = create_access_token(payload.username) return { "access_token": token, "token_type": "bearer", "expires_in_hours": int(os.getenv("ADMIN_JWT_EXPIRE_HOURS", "8")), } @router.get("/dashboard") async def admin_dashboard(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]: sources = await check_sources() return { "time_utc": datetime.now(timezone.utc).isoformat(), "services": sources, "infrastructure": { "postgres": "ok" if pool_available() else "disabled", "redis": "ok" if redis_available() else "disabled", }, "storage": { "data_dir": _dir_stats(DATA_DIR), "radar_raw": _dir_stats(RAW_RADAR_DIR), "radar_rendered": _dir_stats(RENDERED_RADAR_DIR), }, "config": _public_config(), } @router.get("/sources") async def admin_sources(_: dict[str, Any] = Depends(require_admin)) -> 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.", } @router.post("/radar/sync") async def admin_radar_sync( product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"), limit: int = Query(6, ge=1, le=50), _: dict[str, Any] = Depends(require_admin), ) -> dict[str, Any]: try: return await sync_radar_files(product=product, limit=limit) except Exception as exc: raise HTTPException(status_code=502, detail=f"Radar sync failed: {exc}") from exc @router.get("/radar/files") async def admin_radar_files( product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"), _: dict[str, Any] = Depends(require_admin), ) -> 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/admin/radar/files/{product}/{path.name}", } ) return {"product": product, "path": str(target_dir), "count": len(files), "files": files} @router.get("/radar/files/{product}/{filename}") async def admin_radar_file_download( product: str, filename: str, _: dict[str, Any] = Depends(require_admin), ): 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) @router.get("/radar/rendered") async def admin_radar_rendered_list( product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"), limit: int = Query(24, ge=1, le=100), _: dict[str, Any] = Depends(require_admin), ) -> dict[str, Any]: frames = await list_rendered_radar(product, limit=limit) return {"product": product, "count": len(frames), "frames": frames} @router.get("/radar/rendered/{product}/{filename}.png") async def admin_radar_rendered_image( product: str, filename: str, _: dict[str, Any] = Depends(require_admin), ): 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") @router.post("/cache/clear") async def admin_clear_cache(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]: from app.cache import flush_cache if not redis_available(): raise HTTPException(status_code=503, detail="Redis cache is not available") await flush_cache() return {"status": "ok", "message": "Redis cache cleared"} @router.get("/settings") async def admin_get_settings(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]: runtime = await cache_get("admin:runtime_settings") or {} return {"defaults": _public_config(), "runtime_overrides": runtime} @router.put("/settings") async def admin_update_settings(payload: SettingsUpdate, _: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]: from app.cache import cache_set current = await cache_get("admin:runtime_settings") or {} updates = payload.model_dump(exclude_none=True) if not updates: raise HTTPException(status_code=400, detail="No settings provided") current.update(updates) await cache_set("admin:runtime_settings", current, ttl_seconds=86400 * 30) return {"status": "ok", "runtime_overrides": current, "note": "Overrides are stored in Redis. Service restart applies full .env changes."}