HexaWetter v1.2.0: Security, Admin, Radar, UI
All checks were successful
CI / api-check (push) Successful in 20s
CI / frontend-check (push) Successful in 6s

This commit is contained in:
TheOnlyMace
2026-06-18 23:59:08 +02:00
parent 3ebdc96381
commit 5a08d4c4ac
72 changed files with 695 additions and 409 deletions

View File

@@ -1,11 +1,14 @@
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, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from app.auth import (
@@ -37,8 +40,10 @@ from app.config import (
RAW_RADAR_DIR,
RENDERED_RADAR_DIR,
)
from app.database import pool_available
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"])
@@ -87,7 +92,7 @@ def _public_config() -> dict[str, Any]:
@router.get("/status")
async def admin_status_public() -> dict[str, Any]:
return {"admin_enabled": admin_configured(), "version": APP_VERSION}
return {"admin_enabled": admin_configured()}
@router.post("/login")
@@ -97,6 +102,7 @@ async def admin_login(request: Request, payload: LoginRequest) -> dict[str, Any]
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 {
@@ -125,6 +131,95 @@ async def admin_dashboard(_: dict[str, Any] = Depends(require_admin)) -> dict[st
}
@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