HexaWetter v1.2.0: Security, Admin, Radar, UI

This commit is contained in:
TheOnlyMace
2026-06-18 23:59:08 +02:00
parent 7ba9687619
commit 3589f7fdcf
72 changed files with 695 additions and 409 deletions

View File

@@ -1,52 +1,45 @@
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 datetime import datetime, timezone
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 fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from app.auth import admin_configured
from app.cache import close_redis, init_redis, redis_available
from app.cache import close_redis, init_redis
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,
RENDERED_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.database import close_db, init_db
from app.http_client import close_http_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.dwd_radar import 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.security import (
cors_allowed_origins,
is_api_key_exempt,
trusted_hosts,
validate_security_config,
verify_request_api_key,
)
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
@@ -74,6 +67,7 @@ class FavoriteLocation(BaseModel):
@asynccontextmanager
async def lifespan(_: FastAPI):
validate_security_config()
await init_http_client()
await init_db()
await init_redis()
@@ -87,14 +81,32 @@ async def lifespan(_: FastAPI):
app = FastAPI(title=APP_NAME, version=APP_VERSION, lifespan=lifespan)
app.include_router(admin_router)
_hosts = list(trusted_hosts())
if _hosts:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=_hosts)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
allow_origins=cors_allowed_origins(),
allow_methods=["GET", "POST", "PUT", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-API-Key"],
)
@app.middleware("http")
async def api_key_middleware(request: Request, call_next):
path = request.url.path
if path.startswith("/api/") and not is_api_key_exempt(path):
if not verify_request_api_key(request):
return JSONResponse(
status_code=401,
content={"detail": "Invalid or missing API key"},
headers={"WWW-Authenticate": "ApiKey"},
)
return await call_next(request)
@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
response = await call_next(request)
@@ -102,6 +114,9 @@ async def security_headers_middleware(request: Request, call_next):
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "geolocation=(self)"
if path := request.url.path:
if path.startswith("/api/admin") or path.endswith("admin.html"):
response.headers["X-Robots-Tag"] = "noindex, nofollow"
return response
@@ -131,20 +146,6 @@ async def health() -> dict[str, Any]:
"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,
}
@@ -162,18 +163,6 @@ async def dashboard(
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()
@@ -247,88 +236,6 @@ async def radar_latest(
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 {
@@ -391,14 +298,3 @@ async def radar_wms_times(
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.",
}