250 lines
9.0 KiB
Python
250 lines
9.0 KiB
Python
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."}
|