HexaWetter v1.2.0: Security, Admin, Radar, UI
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user