HexaWetter v1.2.0: Security, Admin, Radar, UI
This commit is contained in:
184
api/app/main.py
184
api/app/main.py
@@ -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.",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user