HexaWetter v1.2.0: Security, Admin, Radar, UI
This commit is contained in:
@@ -66,6 +66,14 @@ def decode_access_token(token: str) -> dict[str, Any]:
|
||||
return jwt.decode(token, ADMIN_JWT_SECRET, algorithms=["HS256"])
|
||||
|
||||
|
||||
def _unauthorized(detail: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=detail,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
async def require_admin(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
@@ -73,13 +81,13 @@ async def require_admin(
|
||||
if not admin_configured():
|
||||
raise HTTPException(status_code=503, detail="Admin access is not configured")
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
|
||||
raise _unauthorized("Missing bearer token")
|
||||
try:
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
except JWTError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") from exc
|
||||
raise _unauthorized("Invalid or expired token") from exc
|
||||
if payload.get("sub") != ADMIN_USERNAME or payload.get("scope") != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token scope")
|
||||
raise _unauthorized("Invalid token scope")
|
||||
return payload
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
APP_NAME = "HexaWetter"
|
||||
APP_VERSION = "1.1.0"
|
||||
APP_VERSION = "1.2.0"
|
||||
|
||||
DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
|
||||
RAW_RADAR_DIR = DATA_DIR / "raw" / "radar"
|
||||
@@ -42,6 +42,15 @@ CACHE_TTL_DASHBOARD = int(os.getenv("CACHE_TTL_DASHBOARD", "60"))
|
||||
|
||||
RATE_LIMIT_PER_MINUTE = int(os.getenv("RATE_LIMIT_PER_MINUTE", "120"))
|
||||
|
||||
API_KEY = os.getenv("API_KEY", "")
|
||||
REQUIRE_API_KEY = os.getenv("REQUIRE_API_KEY", "true").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
_allowed_origins = os.getenv("ALLOWED_ORIGINS", "").strip()
|
||||
ALLOWED_ORIGINS = tuple(o.strip() for o in _allowed_origins.split(",") if o.strip())
|
||||
|
||||
_allowed_hosts = os.getenv("ALLOWED_HOSTS", "").strip()
|
||||
ALLOWED_HOSTS = tuple(h.strip() for h in _allowed_hosts.split(",") if h.strip())
|
||||
|
||||
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
|
||||
ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH", "")
|
||||
ADMIN_JWT_SECRET = os.getenv("ADMIN_JWT_SECRET", "")
|
||||
|
||||
@@ -200,7 +200,7 @@ async def list_rendered_radar(product: str, limit: int = 24) -> list[dict[str, A
|
||||
result.append(
|
||||
{
|
||||
"filename": row["filename"],
|
||||
"image_url": f"/api/radar/rendered/{product}/{row['filename']}.png",
|
||||
"image_url": f"/api/admin/radar/rendered/{product}/{row['filename']}.png",
|
||||
"bounds": bounds,
|
||||
"modified_at": row["modified_at"].isoformat() if row["modified_at"] else None,
|
||||
"rendered_at": row["updated_at"].isoformat() if row["updated_at"] else None,
|
||||
|
||||
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.",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
70
api/app/security.py
Normal file
70
api/app/security.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Iterable
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.config import ALLOWED_HOSTS, API_KEY, REQUIRE_API_KEY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_KEY_EXEMPT_PATHS: frozenset[str] = frozenset(
|
||||
{
|
||||
"/api/health",
|
||||
"/api/admin/login",
|
||||
"/api/admin/status",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def api_key_configured() -> bool:
|
||||
return bool(API_KEY)
|
||||
|
||||
|
||||
def api_key_required() -> bool:
|
||||
return REQUIRE_API_KEY or api_key_configured()
|
||||
|
||||
|
||||
def validate_security_config() -> None:
|
||||
if REQUIRE_API_KEY and not API_KEY:
|
||||
raise RuntimeError("REQUIRE_API_KEY is enabled but API_KEY is not set. Generate one with: python scripts/setup_secrets.py")
|
||||
if API_KEY and len(API_KEY) < 24:
|
||||
raise RuntimeError("API_KEY must be at least 24 characters")
|
||||
if api_key_configured() and not REQUIRE_API_KEY:
|
||||
logger.warning("API_KEY is set but REQUIRE_API_KEY=false — API key auth is still enforced when API_KEY is present")
|
||||
if not api_key_configured() and not REQUIRE_API_KEY:
|
||||
logger.warning("API is running without API_KEY — set API_KEY and REQUIRE_API_KEY=true for production")
|
||||
|
||||
|
||||
def is_api_key_exempt(path: str) -> bool:
|
||||
return path in API_KEY_EXEMPT_PATHS
|
||||
|
||||
|
||||
def extract_api_key(request: Request) -> str:
|
||||
return request.headers.get("X-API-Key", "").strip()
|
||||
|
||||
|
||||
def verify_request_api_key(request: Request) -> bool:
|
||||
if not api_key_required():
|
||||
return True
|
||||
provided = extract_api_key(request)
|
||||
if not provided:
|
||||
return False
|
||||
return secrets.compare_digest(provided, API_KEY)
|
||||
|
||||
|
||||
def cors_allowed_origins() -> list[str]:
|
||||
from app.config import ALLOWED_ORIGINS
|
||||
|
||||
if ALLOWED_ORIGINS:
|
||||
return list(ALLOWED_ORIGINS)
|
||||
if api_key_required():
|
||||
return []
|
||||
return ["*"]
|
||||
|
||||
|
||||
def trusted_hosts() -> Iterable[str]:
|
||||
return ALLOWED_HOSTS
|
||||
59
api/app/services/radar_sync.py
Normal file
59
api/app/services/radar_sync.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.config import RADAR_PRODUCT, RADAR_RETENTION_HOURS, RAW_RADAR_DIR
|
||||
from app.database import upsert_radar_meta
|
||||
from app.http_client import get_client
|
||||
from app.services.dwd_radar import RADAR_FILE_RE, fetch_radar_index
|
||||
|
||||
_FILENAME_RE = re.compile(r"^[a-z0-9_-]+$", re.IGNORECASE)
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
async def sync_radar_files(product: str = RADAR_PRODUCT, limit: int = 6) -> dict:
|
||||
if not _FILENAME_RE.fullmatch(product):
|
||||
raise ValueError("Invalid product")
|
||||
|
||||
latest = await fetch_radar_index(product=product, limit=limit)
|
||||
target_dir = RAW_RADAR_DIR / "composite" / product
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
downloaded: list[str] = []
|
||||
skipped: list[str] = []
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -86,11 +86,11 @@ async def fetch_all_warnings() -> list[dict[str, Any]]:
|
||||
|
||||
client = get_client()
|
||||
response = await client.get(DWD_WARNINGS_URL, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
text = response.text
|
||||
if text.startswith("warnWetter.loadWarnings("):
|
||||
text = text[text.index("(") + 1 : text.rindex(")")]
|
||||
payload = json.loads(text)
|
||||
response.raise_for_status()
|
||||
text = response.text
|
||||
if text.startswith("warnWetter.loadWarnings("):
|
||||
text = text[text.index("(") + 1 : text.rindex(")")]
|
||||
payload = json.loads(text)
|
||||
|
||||
warnings = _flatten_warnings(payload)
|
||||
await cache_set(cache_key, warnings, CACHE_TTL_WARNINGS)
|
||||
|
||||
@@ -4,6 +4,7 @@ import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from app.cache import cache_get, cache_set
|
||||
from app.http_client import get_client
|
||||
from app.config import CACHE_TTL_WMS_TIMES, DWD_WMS_URL
|
||||
|
||||
@@ -59,10 +60,10 @@ async def fetch_wms_time_steps(layer: str, minutes: int = 120) -> dict:
|
||||
params = {"service": "WMS", "version": "1.3.0", "request": "GetCapabilities"}
|
||||
client = get_client()
|
||||
response = await client.get(DWD_WMS_URL, params=params, timeout=15.0)
|
||||
response.raise_for_status()
|
||||
dimension = _find_layer_dimension(response.text, layer)
|
||||
if not dimension:
|
||||
raise ValueError(f"No TIME dimension found for layer {layer}")
|
||||
response.raise_for_status()
|
||||
dimension = _find_layer_dimension(response.text, layer)
|
||||
if not dimension:
|
||||
raise ValueError(f"No TIME dimension found for layer {layer}")
|
||||
|
||||
if "/" in dimension:
|
||||
start, end, step_minutes = _parse_time_dimension(dimension)
|
||||
|
||||
Reference in New Issue
Block a user