diff --git a/.env b/.env deleted file mode 100644 index a0d8ce9..0000000 --- a/.env +++ /dev/null @@ -1,54 +0,0 @@ -# HexaWetter basic settings -WEB_PORT=8080 -API_PORT=8081 -DOMAIN=wetter.example.com -TZ=Europe/Berlin - -# Database -POSTGRES_PASSWORD=hexawetter -DATABASE_URL=postgresql://hexawetter:hexawetter@postgres:5432/hexawetter -REDIS_URL=redis://redis:6379/0 - -# API sources (replace with selfhosted instances when available) -OPEN_METEO_BASE_URL=https://api.open-meteo.com/v1 -BRIGHTSKY_BASE_URL=https://api.brightsky.dev -DWD_RADAR_BASE_URL=https://opendata.dwd.de/weather/radar/composite -DWD_WMS_URL=https://maps.dwd.de/geoserver/dwd/wms -DWD_WARNINGS_URL=https://www.dwd.de/DWD/warnungen/warnapp_landkreise/json/warnings.json -NOMINATIM_BASE_URL=https://nominatim.openstreetmap.org -NOMINATIM_USER_AGENT=HexaWetter/1.0 (selfhosted weather) - -# Default location: Hauzenberg, DE -DEFAULT_LAT=48.6546 -DEFAULT_LON=13.6252 -DEFAULT_PLACE=Hauzenberg - -# Radar settings -RADAR_PRODUCT=rv -RADAR_SYNC_LIMIT=12 -RADAR_SYNC_INTERVAL_SECONDS=300 -RADAR_RENDER_INTERVAL_SECONDS=300 -RADAR_RETENTION_HOURS=48 - -# Cache TTL (seconds) -CACHE_TTL_FORECAST=600 -CACHE_TTL_OBSERVATIONS=300 -CACHE_TTL_WARNINGS=120 -CACHE_TTL_GEOCODE=86400 -CACHE_TTL_WMS_TIMES=120 - -# Admin (generate with: python scripts/hash_admin_password.py 'your-secure-password') -ADMIN_USERNAME=admin -ADMIN_PASSWORD_HASH=$$2b$$12$$BBgUdrmwTDiYGyzSRHNbh.2F70pkzBB/B3fIRN9kGlHGy1GUP.YzG -ADMIN_JWT_SECRET=mWXQRD1HANCZ-wPh5w4ZXokSwLmfgL1U-TbGveEELp9CGuwL-eyhooHLU4NzQYNV -ADMIN_JWT_EXPIRE_HOURS=8 -ADMIN_LOGIN_MAX_ATTEMPTS=5 -ADMIN_LOGIN_WINDOW_SECONDS=300 - -# Optional selfhosted profile -OPEN_METEO_PORT=8082 -BRIGHTSKY_PORT=8083 -BRIGHTSKY_DB_PASSWORD=brightsky - -# Optional Traefik -TRAEFIK_CERTRESOLVER=letsencrypt diff --git a/.env.example b/.env.example index f94d9a5..0ce94ec 100644 --- a/.env.example +++ b/.env.example @@ -36,8 +36,18 @@ CACHE_TTL_OBSERVATIONS=300 CACHE_TTL_WARNINGS=120 CACHE_TTL_GEOCODE=86400 CACHE_TTL_WMS_TIMES=120 +CACHE_TTL_HEALTH_SOURCES=90 +CACHE_TTL_DASHBOARD=60 -# Admin (generate with: python scripts/hash_admin_password.py 'your-secure-password') +# Security (generate with: python scripts/setup_secrets.py 'your-secure-password') +API_KEY= +REQUIRE_API_KEY=true +# Comma-separated origins for CORS (empty = same-origin only when API_KEY is set) +ALLOWED_ORIGINS= +# Optional host allowlist, e.g. wetter.example.com,localhost +ALLOWED_HOSTS= + +# Admin (generate with: python scripts/setup_secrets.py 'your-secure-password') # bcrypt-Hashes enthalten $ — im .env fuer docker compose jedes $ als $$ schreiben! ADMIN_USERNAME=admin ADMIN_PASSWORD_HASH= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96d623d --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Secrets & local config +.env + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ + +# Runtime data +data/ + +# IDE / OS +.idea/ +.vscode/ +*.swp +.DS_Store diff --git a/README.md b/README.md index da9f1d4..ba13a1d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ HexaWetter ist ein vollständiges Self-Hosting-Wetterportal mit Forecast, DWD-Beobachtungen, interaktiver Radar-Karte (WMS + Zeitleiste), Warnungen, Geocoding und lokalem Radar-Rendering. +## Repository + +```bash +git clone ssh://git@git.hexahost.dev:8006/smueller/HexaWetter.git +cd HexaWetter +``` + ## Features - **Dashboard**: Aktuelles Wetter, DWD-Station, 48h-Stundenansicht, 7-Tage-Forecast @@ -16,7 +23,7 @@ HexaWetter ist ein vollständiges Self-Hosting-Wetterportal mit Forecast, DWD-Be ```bash pip install bcrypt -python scripts/hash_admin_password.py 'dein-sicheres-passwort' +python scripts/setup_secrets.py 'dein-sicheres-passwort' ``` Die ausgegebenen Werte in `.env` eintragen, dann Stack neu starten. @@ -24,11 +31,14 @@ Die ausgegebenen Werte in `.env` eintragen, dann Stack neu starten. Admin-UI: `http://localhost:8080/admin.html` Sicherheit: +- **API-Key** (`API_KEY`) für alle `/api/*`-Endpunkte (Nginx setzt den Header automatisch) - bcrypt-Passwort-Hash (niemals Klartext in `.env` committen) - JWT mit `ADMIN_JWT_SECRET` (min. 32 Zeichen) - Rate-Limiting auf Login (5 Versuche / 5 Min.) - Token nur in `sessionStorage` (nicht localStorage) - Security-Headers auf allen API-Antworten +- API-Port nur auf `127.0.0.1` gebunden (kein direkter externer Zugriff) +- Schreibende Endpunkte (Radar-Sync, Cache leeren, Einstellungen) nur im Admin-Bereich ```bash @@ -36,7 +46,7 @@ cp .env.example .env docker compose up -d --build ``` -Web-UI: `http://localhost:8080` · API: `http://localhost:8081/api/health` +Web-UI: `http://localhost:8080` · API (lokal): `http://127.0.0.1:8081/api/health` ## Architektur @@ -59,7 +69,8 @@ Web-UI: `http://localhost:8080` · API: `http://localhost:8081/api/health` | `GET /api/warnings` | DWD-Warnungen (standortbezogen) | | `GET /api/geocode/search` | Ortssuche | | `GET /api/radar/wms/times` | WMS-Zeitschritte für Animation | -| `GET /api/radar/rendered` | Lokal gerenderte Radar-Frames | +| `GET /api/admin/radar/files` | Lokale Radar-Rohdaten (Admin) | +| `GET /api/admin/radar/rendered` | Gerenderte Radar-Frames (Admin) | ## Selfhosted Forecast (optional) diff --git a/api/app/auth.py b/api/app/auth.py index be59e61..e0202b9 100644 --- a/api/app/auth.py +++ b/api/app/auth.py @@ -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 diff --git a/api/app/config.py b/api/app/config.py index d1fced1..a748a57 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -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", "") diff --git a/api/app/database.py b/api/app/database.py index 3152c64..7e1c1a6 100644 --- a/api/app/database.py +++ b/api/app/database.py @@ -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, diff --git a/api/app/main.py b/api/app/main.py index 92e208c..9000edf 100644 --- a/api/app/main.py +++ b/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.", - } diff --git a/api/app/routes/admin.py b/api/app/routes/admin.py index d51ca2f..a63dc2a 100644 --- a/api/app/routes/admin.py +++ b/api/app/routes/admin.py @@ -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 diff --git a/api/app/security.py b/api/app/security.py new file mode 100644 index 0000000..997e758 --- /dev/null +++ b/api/app/security.py @@ -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 diff --git a/api/app/services/radar_sync.py b/api/app/services/radar_sync.py new file mode 100644 index 0000000..95afeb2 --- /dev/null +++ b/api/app/services/radar_sync.py @@ -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, + } diff --git a/api/app/services/warnings.py b/api/app/services/warnings.py index 01b453e..049a036 100644 --- a/api/app/services/warnings.py +++ b/api/app/services/warnings.py @@ -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) diff --git a/api/app/services/wms.py b/api/app/services/wms.py index 5160b0d..71101bd 100644 --- a/api/app/services/wms.py +++ b/api/app/services/wms.py @@ -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) diff --git a/data/processed/radar/rv/composite_rv_20260618_2025.tar.png b/data/processed/radar/rv/composite_rv_20260618_2025.tar.png deleted file mode 100644 index 4c36a33..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2025.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2030.tar.png b/data/processed/radar/rv/composite_rv_20260618_2030.tar.png deleted file mode 100644 index 49f4664..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2030.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2035.tar.png b/data/processed/radar/rv/composite_rv_20260618_2035.tar.png deleted file mode 100644 index c7261f8..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2035.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2040.tar.png b/data/processed/radar/rv/composite_rv_20260618_2040.tar.png deleted file mode 100644 index 9d0cefa..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2040.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2045.tar.png b/data/processed/radar/rv/composite_rv_20260618_2045.tar.png deleted file mode 100644 index c288ba2..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2045.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2050.tar.png b/data/processed/radar/rv/composite_rv_20260618_2050.tar.png deleted file mode 100644 index ee7c069..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2050.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2055.tar.png b/data/processed/radar/rv/composite_rv_20260618_2055.tar.png deleted file mode 100644 index 5cbdaab..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2055.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2100.tar.png b/data/processed/radar/rv/composite_rv_20260618_2100.tar.png deleted file mode 100644 index 01e9a86..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2100.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2105.tar.png b/data/processed/radar/rv/composite_rv_20260618_2105.tar.png deleted file mode 100644 index bd6c7d9..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2105.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2110.tar.png b/data/processed/radar/rv/composite_rv_20260618_2110.tar.png deleted file mode 100644 index 8b51a9f..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2110.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2115.tar.png b/data/processed/radar/rv/composite_rv_20260618_2115.tar.png deleted file mode 100644 index b025e6a..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2115.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2120.tar.png b/data/processed/radar/rv/composite_rv_20260618_2120.tar.png deleted file mode 100644 index 4113f6d..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2120.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2125.tar.png b/data/processed/radar/rv/composite_rv_20260618_2125.tar.png deleted file mode 100644 index 1946624..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2125.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2130.tar.png b/data/processed/radar/rv/composite_rv_20260618_2130.tar.png deleted file mode 100644 index 228619c..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2130.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_LATEST.tar.png b/data/processed/radar/rv/composite_rv_LATEST.tar.png deleted file mode 100644 index c288ba2..0000000 Binary files a/data/processed/radar/rv/composite_rv_LATEST.tar.png and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606181945.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606181945.tar.bz2 deleted file mode 100644 index b2ef5e5..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606181945.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606181950.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606181950.tar.bz2 deleted file mode 100644 index b01ec87..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606181950.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606181955.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606181955.tar.bz2 deleted file mode 100644 index 409adbb..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606181955.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606182000.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606182000.tar.bz2 deleted file mode 100644 index c197d98..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606182000.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606182005.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606182005.tar.bz2 deleted file mode 100644 index 4262d36..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606182005.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606182010.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606182010.tar.bz2 deleted file mode 100644 index bc3e158..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606182010.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606182015.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606182015.tar.bz2 deleted file mode 100644 index 0185a88..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606182015.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606182020.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606182020.tar.bz2 deleted file mode 100644 index 6f6fc58..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606182020.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606182025.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606182025.tar.bz2 deleted file mode 100644 index d22d00a..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606182025.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606182030.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606182030.tar.bz2 deleted file mode 100644 index 1d4f9f8..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606182030.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606182035.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606182035.tar.bz2 deleted file mode 100644 index 4dd065e..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606182035.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606182040.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606182040.tar.bz2 deleted file mode 100644 index 986a3b0..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606182040.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV_LATEST.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV_LATEST.tar.bz2 deleted file mode 100644 index 4dd065e..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV_LATEST.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_1955.tar b/data/raw/radar/composite/rv/composite_rv_20260618_1955.tar deleted file mode 100644 index b583922..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_1955.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2000.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2000.tar deleted file mode 100644 index 8f85db1..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2000.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2005.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2005.tar deleted file mode 100644 index 23ab770..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2005.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2010.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2010.tar deleted file mode 100644 index d73e680..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2010.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2015.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2015.tar deleted file mode 100644 index fb635b1..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2015.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2020.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2020.tar deleted file mode 100644 index faeca92..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2020.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2025.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2025.tar deleted file mode 100644 index e3564b3..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2025.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2030.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2030.tar deleted file mode 100644 index 82c3fa8..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2030.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2035.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2035.tar deleted file mode 100644 index de31dd1..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2035.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2040.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2040.tar deleted file mode 100644 index 8b95c4c..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2040.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2045.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2045.tar deleted file mode 100644 index c0ffc01..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2045.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2050.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2050.tar deleted file mode 100644 index 85d1584..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2050.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2055.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2055.tar deleted file mode 100644 index 4b37444..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2055.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2100.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2100.tar deleted file mode 100644 index bb5978a..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2100.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2105.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2105.tar deleted file mode 100644 index f5f0a71..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2105.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2110.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2110.tar deleted file mode 100644 index 3e795c5..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2110.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2115.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2115.tar deleted file mode 100644 index 26ae847..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2115.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2120.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2120.tar deleted file mode 100644 index a6404f4..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2120.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2125.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2125.tar deleted file mode 100644 index 0cf8a0a..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2125.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2130.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2130.tar deleted file mode 100644 index c73ff94..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2130.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2135.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2135.tar deleted file mode 100644 index a4027f4..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2135.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_LATEST.tar b/data/raw/radar/composite/rv/composite_rv_LATEST.tar deleted file mode 100644 index c0ffc01..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_LATEST.tar and /dev/null differ diff --git a/docker-compose.yml b/docker-compose.yml index d56c81c..8e4ff37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,7 +42,7 @@ services: volumes: - ./data:/data ports: - - "${API_PORT:-8081}:8080" + - "127.0.0.1:${API_PORT:-8081}:8080" depends_on: postgres: condition: service_healthy @@ -53,11 +53,13 @@ services: image: nginx:1.27-alpine container_name: hexawetter-frontend restart: unless-stopped + environment: + API_KEY: ${API_KEY:-} depends_on: - api volumes: - ./frontend:/usr/share/nginx/html:ro - - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./nginx.conf.template:/etc/nginx/templates/default.conf.template:ro ports: - "${WEB_PORT:-8080}:80" diff --git a/frontend/admin.html b/frontend/admin.html index aab6682..00f0c1c 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -3,6 +3,7 @@ + Admin – HexaWetter @@ -47,6 +48,7 @@
+
@@ -80,6 +82,20 @@

+ +
+
+

Radar-Rohdaten

Lokale DWD-Dateien und gerenderte Frames

+ +
+
+ + + +
DateiGrößeGeändert
+
+
+
diff --git a/frontend/admin.js b/frontend/admin.js index af302f2..82a8cb2 100644 --- a/frontend/admin.js +++ b/frontend/admin.js @@ -35,6 +35,67 @@ function formatBytes(bytes) { return `${v.toFixed(u === 0 ? 0 : 1)} ${units[u]}`; } +async function fetchBlobUrl(path) { + const token = getToken(); + const res = await fetch(path, { headers: token ? { Authorization: `Bearer ${token}` } : {} }); + if (!res.ok) throw new Error(`${res.status}`); + const blob = await res.blob(); + return URL.createObjectURL(blob); +} + +async function downloadAdminFile(path, filename) { + const url = await fetchBlobUrl(path); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +async function loadRadarData() { + try { + const local = await api("/api/admin/radar/files"); + el("radarFiles").innerHTML = local.files.length ? local.files.slice().reverse().map((file) => ` + + ${file.name} + ${formatBytes(file.size_bytes)} + ${new Date(file.modified_utc).toLocaleString("de-DE")} + + + `).join("") : `Noch keine lokalen Radar-Dateien.`; + el("radarFiles").querySelectorAll("[data-download]").forEach((btn) => { + btn.addEventListener("click", () => downloadAdminFile(btn.dataset.download, btn.dataset.name)); + }); + } catch { + el("radarFiles").innerHTML = `Rohdaten nicht verfügbar`; + } + + try { + const rendered = await api("/api/admin/radar/rendered?limit=12"); + el("renderedCount").textContent = `${rendered.count} Frame(s)`; + const frames = rendered.frames || []; + if (!frames.length) { + el("renderedFrames").innerHTML = `

Noch keine gerenderten Frames.

`; + return; + } + el("renderedFrames").innerHTML = frames.map((f, i) => ` +
+
Lade…
+
${f.filename}
+
+ `).join(""); + await Promise.allSettled(frames.map(async (f, i) => { + const url = await fetchBlobUrl(f.image_url); + const figure = el(`renderedFrame${i}`); + if (!figure) return; + figure.innerHTML = `Radar ${f.filename}
${f.filename}
`; + })); + } catch { + el("renderedCount").textContent = "–"; + el("renderedFrames").innerHTML = `

Renderer nicht verfügbar.

`; + } +} + function showLogin() { el("loginSection").classList.remove("admin-hidden"); el("dashboardSection").classList.add("admin-hidden"); @@ -92,6 +153,7 @@ async function loadDashboard() { el("setCacheObs").value = overrides.cache_ttl_observations ?? defaults.cache_ttl.observations; el("setCacheWarn").value = overrides.cache_ttl_warnings ?? defaults.cache_ttl.warnings; el("setRadarRetention").value = overrides.radar_retention_hours ?? defaults.radar_retention_hours; + await loadRadarData(); } el("loginForm").addEventListener("submit", async (e) => { @@ -124,6 +186,23 @@ el("clearCacheBtn").addEventListener("click", async () => { await loadDashboard(); }); +el("syncRadarBtn").addEventListener("click", async () => { + const button = el("syncRadarBtn"); + button.disabled = true; + const label = button.textContent; + button.textContent = "Sync läuft…"; + try { + const result = await api("/api/admin/radar/sync?limit=6", { method: "POST" }); + alert(`Sync abgeschlossen: ${result.downloaded?.length || 0} neu, ${result.skipped?.length || 0} übersprungen`); + await loadRadarData(); + } catch (err) { + alert(err.message); + } finally { + button.disabled = false; + button.textContent = label; + } +}); + el("settingsForm").addEventListener("submit", async (e) => { e.preventDefault(); const payload = { diff --git a/frontend/app.js b/frontend/app.js index b3d39c0..562368e 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -10,12 +10,11 @@ let layerControl = null; let osmLayer = null; let radarLayer = null; let warningLayer = null; -let localRadarOverlay = null; let radarTimes = []; let radarFrameIndex = 0; let radarPlayTimer = null; let radarPlaying = false; -let renderedFrames = []; +let hourlyChart = null; let settings = loadSettings(); let searchTimer = null; @@ -44,14 +43,6 @@ function saveFavorites(items) { localStorage.setItem(FAV_KEY, JSON.stringify(items)); } -function formatBytes(bytes) { - if (!bytes) return "–"; - const units = ["B", "KB", "MB", "GB"]; - let value = bytes, unit = 0; - while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; } - return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; -} - function weatherText(code) { const map = { 0: "Klar", 1: "Überwiegend klar", 2: "Teils bewölkt", 3: "Bewölkt", @@ -74,6 +65,139 @@ function convertWind(kmh) { return `${Math.round(kmh)} km/h`; } +function tempValue(celsius) { + if (settings.unitTemp === "fahrenheit") return celsius * 9 / 5 + 32; + return celsius; +} + +function tempUnitLabel() { + return settings.unitTemp === "fahrenheit" ? "°F" : "°C"; +} + +function windUnitLabel() { + if (settings.unitWind === "ms") return "m/s"; + if (settings.unitWind === "kn") return "kn"; + return "km/h"; +} + +function windValue(kmh) { + if (settings.unitWind === "ms") return kmh / 3.6; + if (settings.unitWind === "kn") return kmh * 0.539957; + return kmh; +} + +function chartThemeColors() { + const isLight = document.documentElement.dataset.theme === "light"; + return { + text: isLight ? "#5f5380" : "#cfc9dd", + grid: isLight ? "rgba(20, 10, 60, 0.08)" : "rgba(255, 255, 255, 0.08)", + }; +} + +function destroyHourlyChart() { + if (hourlyChart) { + hourlyChart.destroy(); + hourlyChart = null; + } +} + +function renderHourlyChart(hourly) { + if (typeof Chart === "undefined" || !hourly?.time?.length) return; + const canvas = el("hourlyChart"); + if (!canvas) return; + + destroyHourlyChart(); + const { text: textColor, grid: gridColor } = chartThemeColors(); + const tempUnit = tempUnitLabel(); + const windUnit = windUnitLabel(); + const labels = hourly.time.slice(0, 48).map((time) => + new Date(time).toLocaleString("de-DE", { weekday: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" }) + ); + + hourlyChart = new Chart(canvas, { + type: "line", + data: { + labels, + datasets: [ + { + type: "bar", + label: "Regenwahrscheinlichkeit", + data: hourly.precipitation_probability.slice(0, 48).map((v) => v ?? 0), + yAxisID: "yRain", + backgroundColor: "rgba(57, 120, 255, 0.42)", + borderColor: "rgba(57, 120, 255, 0.7)", + borderWidth: 1, + borderRadius: 4, + order: 3, + }, + { + label: `Temperatur (${tempUnit})`, + data: hourly.temperature_2m.slice(0, 48).map(tempValue), + yAxisID: "yMain", + borderColor: "#ff51f9", + backgroundColor: "rgba(255, 81, 249, 0.14)", + fill: true, + tension: 0.35, + pointRadius: 0, + pointHoverRadius: 4, + borderWidth: 2.5, + order: 1, + }, + { + label: `Wind (${windUnit})`, + data: hourly.wind_speed_10m.slice(0, 48).map((v) => windValue(v ?? 0)), + yAxisID: "yMain", + borderColor: "#a348ff", + borderDash: [5, 4], + tension: 0.35, + pointRadius: 0, + borderWidth: 1.5, + fill: false, + order: 2, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index", intersect: false }, + plugins: { + legend: { + labels: { color: textColor, usePointStyle: true, boxWidth: 10 }, + }, + tooltip: { + callbacks: { + label(ctx) { + const label = ctx.dataset.label || ""; + if (label.startsWith("Regen")) return `${label}: ${ctx.parsed.y}%`; + if (label.startsWith("Temperatur")) return `${label}: ${Math.round(ctx.parsed.y)} ${tempUnit}`; + return `${label}: ${ctx.parsed.y.toFixed(1)} ${windUnit}`; + }, + }, + }, + }, + scales: { + x: { + ticks: { color: textColor, maxRotation: 0, autoSkip: true, maxTicksLimit: 14 }, + grid: { color: gridColor }, + }, + yMain: { + position: "left", + ticks: { color: textColor }, + grid: { color: gridColor }, + }, + yRain: { + position: "right", + min: 0, + max: 100, + grid: { drawOnChartArea: false }, + ticks: { color: textColor, callback: (v) => `${v}%` }, + }, + }, + }, + }); +} + async function api(path, options = {}) { const res = await fetch(path, options); if (!res.ok) { @@ -126,15 +250,6 @@ async function loadHealth() { } } -async function loadSourceStatus() { - try { - const data = await api("/api/health/sources"); - renderSourceStatus(data.sources || []); - } catch { - el("sourceStatusSummary").textContent = "Quellenstatus nicht verfügbar"; - } -} - function applyDashboard(dash) { const loc = getLocation(); if (dash.forecast?.data) { @@ -207,18 +322,6 @@ function applyWarningsData(data) { `).join("") : `

${localOnly ? "Keine aktiven Warnungen für deinen Standort." : "Keine Warnungen verfügbar."}

`; } -function renderSourceStatus(sources) { - const bad = sources.filter((s) => s.status === "error").length; - el("sourceStatusSummary").textContent = bad ? `${bad} Quelle(n) mit Problemen` : "Alle Quellen erreichbar"; - el("sourceStatusGrid").innerHTML = sources.map((s) => ` -
- ${s.name} - ${s.status}${s.latency_ms ? ` · ${s.latency_ms} ms` : ""} - ${s.error ? `${s.error}` : ""} -
- `).join(""); -} - async function loadForecast() { const loc = getLocation(); const data = await api(`/api/forecast?lat=${loc.lat}&lon=${loc.lon}`); @@ -270,6 +373,7 @@ function renderHourly(hourly) { ${convertWind(hourly.wind_gusts_10m[idx] ?? 0)} `).join(""); + renderHourlyChart(hourly); } function renderHourStrip(hourly) { @@ -306,33 +410,7 @@ async function loadWarnings() { const localOnly = el("warningsLocalOnly")?.checked ?? true; try { const data = await api(`/api/warnings?lat=${loc.lat}&lon=${loc.lon}&local_only=${localOnly}`); - const warnings = data.warnings || []; - el("warnCount").textContent = String(data.count_local ?? warnings.length); - el("warnMeta").textContent = localOnly - ? `${warnings.length} Warnung(en) für deinen Standort` - : `${data.count_total} Warnungen bundesweit (Top ${warnings.length})`; - - const warncellEl = el("warncellInfo"); - if (localOnly && data.warncells?.length) { - warncellEl.classList.remove("hidden"); - warncellEl.innerHTML = `Zugeordnete Warngebiete: ${data.warncells.map((a) => `${a.name} (${a.warncell_id})`).join(" · ")}`; - } else { - warncellEl.classList.add("hidden"); - warncellEl.innerHTML = ""; - } - - el("warningsList").innerHTML = warnings.length ? warnings.map((w) => ` -
-
- ${w.headline || w.event} - Level ${w.level} · ${w.level_label} -
-

${w.region_name || ""} · ${w.state || ""}

-

${(w.description || "").replace(/\n/g, "
")}

- ${w.instruction ? `

${w.instruction}

` : ""} - Gültig: ${w.start ? formatDateTime(w.start) : "–"} – ${w.end ? formatDateTime(w.end) : "–"} -
- `).join("") : `

${localOnly ? "Keine aktiven Warnungen für deinen Standort." : "Keine Warnungen verfügbar."}

`; + applyWarningsData(data); } catch (err) { el("warnCount").textContent = "–"; el("warnMeta").textContent = err.message; @@ -341,56 +419,6 @@ async function loadWarnings() { } } -async function loadRadarFiles() { - const latest = await api("/api/radar/latest?limit=12"); - el("radarCount").textContent = `${latest.count} online`; - el("radarMeta").textContent = `Produkt ${latest.product.toUpperCase()} · DWD OpenData`; - const local = await api("/api/radar/files"); - el("radarFiles").innerHTML = local.files.length ? local.files.slice().reverse().map((file) => ` - ${file.name}${formatBytes(file.size_bytes)} - ${new Date(file.modified_utc).toLocaleString("de-DE")} - Download - `).join("") : `Noch keine lokalen Radar-Dateien.`; - try { - const rendered = await api("/api/radar/rendered?limit=12"); - renderedFrames = rendered.frames || []; - el("renderedCount").textContent = `${rendered.count} Frame(s)`; - el("renderedFrames").innerHTML = renderedFrames.length ? renderedFrames.map((f) => ` -
- Radar ${f.filename} -
${f.filename}
-
- `).join("") : `

Noch keine gerenderten Frames. Renderer-Worker abwarten.

`; - } catch { - el("renderedCount").textContent = "–"; - el("renderedFrames").innerHTML = `

Renderer nicht verfügbar.

`; - } -} - -async function syncRadar() { - const button = el("syncRadarBtn"); - button.disabled = true; - button.textContent = "Sync läuft…"; - try { - await api("/api/radar/sync?limit=6", { method: "POST" }); - await loadRadarFiles(); - } finally { - button.disabled = false; - button.textContent = "Radar jetzt syncen"; - } -} - -async function loadSources() { - try { - const data = await api("/api/sources"); - el("sourceList").innerHTML = Object.entries(data).map(([key, value]) => ` -
${key}${String(value)}
- `).join(""); - } catch (err) { - el("sourceList").innerHTML = `

${err.message}

`; - } -} - function setRadarTime(index) { if (!radarTimes.length) return; radarFrameIndex = Math.max(0, Math.min(index, radarTimes.length - 1)); @@ -399,24 +427,11 @@ function setRadarTime(index) { el("radarTimeLabel").textContent = formatDateTime(time); el("radarFrameInfo").textContent = `Frame ${radarFrameIndex + 1} / ${radarTimes.length}`; - if (el("useLocalRadar").checked && renderedFrames.length) { - showLocalRadarFrame(radarFrameIndex); - } else if (radarLayer) { + if (radarLayer) { radarLayer.setParams({ TIME: time, _cache: Date.now() }); } } -function showLocalRadarFrame(index) { - if (!map) return; - const frame = renderedFrames[Math.min(index, renderedFrames.length - 1)]; - if (!frame?.bounds) return; - const b = frame.bounds; - const bounds = [[b.south, b.west], [b.north, b.east]]; - if (localRadarOverlay) map.removeLayer(localRadarOverlay); - localRadarOverlay = L.imageOverlay(frame.image_url + `?t=${Date.now()}`, bounds, { opacity: 0.75 }); - localRadarOverlay.addTo(map); -} - async function loadRadarTimes() { const layer = radarLayer?.wmsParams?.layers || "dwd:Niederschlagsradar"; const minutes = settings.radarMinutes || 120; @@ -600,11 +615,33 @@ function activateView(name) { document.querySelectorAll(".view").forEach((view) => view.classList.toggle("active", view.dataset.view === name)); if (name === "radar") initMap().then(() => setTimeout(() => map?.invalidateSize(), 150)); if (name === "warnings") loadWarnings(); + if (name === "hourly" && hourlyChart) setTimeout(() => hourlyChart.resize(), 80); +} + +async function loadDashboard() { + const loc = getLocation(); + const localOnly = el("warningsLocalOnly")?.checked ?? true; + const dash = await api(`/api/dashboard?lat=${loc.lat}&lon=${loc.lon}&local_only=${localOnly}`); + applyDashboard(dash); + if (dash.bundle_cached) { + el("forecastSource").textContent += " · Bundle-Cache"; + } + return dash; } async function loadAll() { - await loadHealth(); - await Promise.allSettled([loadForecast(), loadObservations(), loadRadarFiles(), loadSources(), loadWarnings()]); + loadHealth(); + const loc = getLocation(); + try { + await loadDashboard(); + } catch { + await Promise.allSettled([loadForecast(), loadObservations(), loadWarnings()]); + const radar = await api("/api/radar/latest?limit=12").catch(() => null); + if (radar) { + el("radarCount").textContent = `${radar.count} online`; + el("radarMeta").textContent = `Produkt ${radar.product.toUpperCase()} · DWD OpenData`; + } + } updateMapLocation(); if (map) loadRadarTimes(); } @@ -614,20 +651,17 @@ async function init() { await loadDefaultLocation(); renderFavorites(); await loadAll(); - setInterval(loadHealth, 30000); + setInterval(loadHealth, 60000); } document.querySelectorAll(".tab").forEach((tab) => tab.addEventListener("click", () => activateView(tab.dataset.view))); el("loadBtn").addEventListener("click", loadAll); -el("syncRadarBtn").addEventListener("click", syncRadar); el("centerMapBtn").addEventListener("click", updateMapLocation); el("reloadRadarLayersBtn").addEventListener("click", reloadRadarLayers); el("favBtn").addEventListener("click", addFavorite); el("geoBtn").addEventListener("click", useGeolocation); el("warningsLocalOnly")?.addEventListener("change", loadWarnings); el("showWarningsOnMap")?.addEventListener("change", toggleWarningsOnMap); -el("useLocalRadar")?.addEventListener("change", () => setRadarTime(radarFrameIndex)); - el("radarPlayBtn").addEventListener("click", toggleRadarPlay); el("radarPrevBtn").addEventListener("click", () => setRadarTime(radarFrameIndex - 1)); el("radarNextBtn").addEventListener("click", () => setRadarTime(radarFrameIndex + 1)); @@ -647,6 +681,7 @@ el("themeToggle").addEventListener("click", () => { settings.theme = settings.theme === "dark" ? "light" : "dark"; saveSettings(); applyTheme(); + if (latestForecast?.data?.hourly) renderHourlyChart(latestForecast.data.hourly); }); ["unitTemp", "unitWind", "radarMinutes"].forEach((id) => { @@ -656,7 +691,12 @@ el("themeToggle").addEventListener("click", () => { if (id === "unitWind") settings.unitWind = e.target.value; if (id === "radarMinutes") settings.radarMinutes = Number(e.target.value); saveSettings(); - if (latestForecast) { renderDaily(latestForecast.data.daily); renderHourly(latestForecast.data.hourly); renderHourStrip(latestForecast.data.hourly); loadForecast(); } + if (latestForecast) { + renderDaily(latestForecast.data.daily); + renderHourly(latestForecast.data.hourly); + renderHourStrip(latestForecast.data.hourly); + loadForecast(); + } if (id === "radarMinutes" && map) loadRadarTimes(); }); }); diff --git a/frontend/index.html b/frontend/index.html index 174ea5c..eacbfa8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -73,7 +73,6 @@ - @@ -92,10 +91,6 @@

Kurzvorschau

Nächste Stunden
-
-

Datenquellen-Status

Prüfe…
-
-
@@ -116,7 +111,6 @@
-
@@ -144,8 +138,21 @@
-

Stundenansicht

48 Stunden
-
ZeitWetterTemp.RegenWolkenWindBöen
+
+

Stundenansicht

+ 48 Stunden · Temperatur, Regen & Wind +
+
+ +
+
+ + + + + +
ZeitWetterTemp.RegenWolkenWindBöen
+
@@ -156,24 +163,6 @@
-
-
-
-

Radar-Rohdaten

DWD OpenData · lokaler Renderer

- -
-
DateiGrößeGeändertDownload
-
-
-

Gerenderte Frames

-
-
-
-

Datenquellen

-
-
-
-

Einstellungen

@@ -213,6 +202,7 @@ + diff --git a/frontend/style.css b/frontend/style.css index 10cfd40..80a360b 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -307,6 +307,19 @@ html[data-theme="light"] .search-results { background: #fff; } grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; } + +.hourly-chart-wrap { + position: relative; + height: min(340px, 48vh); + margin-bottom: 1.25rem; + padding: 0.75rem 0.5rem 0.25rem; + border: 1px solid var(--glass-border); + border-radius: var(--radius-xl); + background: rgba(0, 0, 0, 0.14); +} +html[data-theme="light"] .hourly-chart-wrap { + background: rgba(255, 255, 255, 0.55); +} .hour-card span { display: block; font-size: 1.35rem; font-weight: 800; margin: 0.4rem 0; } .bar { height: 6px; border-radius: 999px; overflow: hidden; background: rgba(255,255,255,0.12); margin: 0.7rem 0; } .bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--accent-color-2), var(--primary-color)); } diff --git a/nginx.conf b/nginx.conf.template similarity index 92% rename from nginx.conf rename to nginx.conf.template index 687b7b0..249e044 100644 --- a/nginx.conf +++ b/nginx.conf.template @@ -14,6 +14,7 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-API-Key ${API_KEY}; proxy_connect_timeout 10s; proxy_read_timeout 120s; } diff --git a/scripts/hash_admin_password.py b/scripts/hash_admin_password.py index 8474243..e80f40b 100644 --- a/scripts/hash_admin_password.py +++ b/scripts/hash_admin_password.py @@ -1,38 +1,10 @@ #!/usr/bin/env python3 -"""Generate bcrypt hash and JWT secret for HexaWetter admin login.""" +"""Deprecated wrapper — use scripts/setup_secrets.py instead.""" from __future__ import annotations -import argparse -import secrets - -import bcrypt - - -def hash_password(plain_password: str) -> str: - return bcrypt.hashpw(plain_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") - - -def docker_compose_escape(value: str) -> str: - """Escape $ for docker compose .env interpolation ($$ -> literal $).""" - return value.replace("$", "$$") - - -def main() -> None: - parser = argparse.ArgumentParser(description="Generate HexaWetter admin credentials") - parser.add_argument("password", help="Admin password (min. 8 characters)") - args = parser.parse_args() - if len(args.password) < 8: - raise SystemExit("Password must be at least 8 characters") - - password_hash = hash_password(args.password) - jwt_secret = secrets.token_urlsafe(48) - - print("Add these lines to your .env file:\n") - print("ADMIN_USERNAME=admin") - print(f"ADMIN_PASSWORD_HASH={docker_compose_escape(password_hash)}") - print(f"ADMIN_JWT_SECRET={jwt_secret}") - print("\n# Hinweis: bcrypt-Hashes enthalten $. Fuer docker compose muss jedes $ als $$ escaped sein.") - +import runpy +import sys if __name__ == "__main__": - main() + sys.argv[0] = "scripts/setup_secrets.py" + runpy.run_path("scripts/setup_secrets.py", run_name="__main__") diff --git a/scripts/setup_secrets.py b/scripts/setup_secrets.py new file mode 100644 index 0000000..8e6eacf --- /dev/null +++ b/scripts/setup_secrets.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Generate API key and admin credentials for HexaWetter.""" +from __future__ import annotations + +import argparse +import secrets + +import bcrypt + + +def hash_password(plain_password: str) -> str: + return bcrypt.hashpw(plain_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def docker_compose_escape(value: str) -> str: + """Escape $ for docker compose .env interpolation ($$ -> literal $).""" + return value.replace("$", "$$") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate HexaWetter security secrets") + parser.add_argument("password", nargs="?", help="Admin password (min. 8 characters)") + parser.add_argument("--api-key-only", action="store_true", help="Only generate API_KEY") + args = parser.parse_args() + + api_key = secrets.token_urlsafe(32) + print("Add these lines to your .env file:\n") + print(f"API_KEY={api_key}") + print("REQUIRE_API_KEY=true") + + if args.api_key_only: + return + + if not args.password: + raise SystemExit("Admin password required unless --api-key-only is set") + if len(args.password) < 8: + raise SystemExit("Password must be at least 8 characters") + + password_hash = hash_password(args.password) + jwt_secret = secrets.token_urlsafe(48) + + print() + print("ADMIN_USERNAME=admin") + print(f"ADMIN_PASSWORD_HASH={docker_compose_escape(password_hash)}") + print(f"ADMIN_JWT_SECRET={jwt_secret}") + print("\n# Hinweis: bcrypt-Hashes enthalten $. Fuer docker compose muss jedes $ als $$ escaped sein.") + + +if __name__ == "__main__": + main()