HexaWetter v1.2.0: Security, Admin, Radar, UI
All checks were successful
CI / api-check (push) Successful in 20s
CI / frontend-check (push) Successful in 6s

This commit is contained in:
TheOnlyMace
2026-06-18 23:59:08 +02:00
parent 3ebdc96381
commit 5a08d4c4ac
72 changed files with 695 additions and 409 deletions

54
.env
View File

@@ -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

View File

@@ -36,8 +36,18 @@ CACHE_TTL_OBSERVATIONS=300
CACHE_TTL_WARNINGS=120 CACHE_TTL_WARNINGS=120
CACHE_TTL_GEOCODE=86400 CACHE_TTL_GEOCODE=86400
CACHE_TTL_WMS_TIMES=120 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! # bcrypt-Hashes enthalten $ — im .env fuer docker compose jedes $ als $$ schreiben!
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH= ADMIN_PASSWORD_HASH=

18
.gitignore vendored Normal file
View File

@@ -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

View File

@@ -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. 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 ## Features
- **Dashboard**: Aktuelles Wetter, DWD-Station, 48h-Stundenansicht, 7-Tage-Forecast - **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 ```bash
pip install bcrypt 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. 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` Admin-UI: `http://localhost:8080/admin.html`
Sicherheit: Sicherheit:
- **API-Key** (`API_KEY`) für alle `/api/*`-Endpunkte (Nginx setzt den Header automatisch)
- bcrypt-Passwort-Hash (niemals Klartext in `.env` committen) - bcrypt-Passwort-Hash (niemals Klartext in `.env` committen)
- JWT mit `ADMIN_JWT_SECRET` (min. 32 Zeichen) - JWT mit `ADMIN_JWT_SECRET` (min. 32 Zeichen)
- Rate-Limiting auf Login (5 Versuche / 5 Min.) - Rate-Limiting auf Login (5 Versuche / 5 Min.)
- Token nur in `sessionStorage` (nicht localStorage) - Token nur in `sessionStorage` (nicht localStorage)
- Security-Headers auf allen API-Antworten - 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 ```bash
@@ -36,7 +46,7 @@ cp .env.example .env
docker compose up -d --build 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 ## Architektur
@@ -59,7 +69,8 @@ Web-UI: `http://localhost:8080` · API: `http://localhost:8081/api/health`
| `GET /api/warnings` | DWD-Warnungen (standortbezogen) | | `GET /api/warnings` | DWD-Warnungen (standortbezogen) |
| `GET /api/geocode/search` | Ortssuche | | `GET /api/geocode/search` | Ortssuche |
| `GET /api/radar/wms/times` | WMS-Zeitschritte für Animation | | `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) ## Selfhosted Forecast (optional)

View File

@@ -66,6 +66,14 @@ def decode_access_token(token: str) -> dict[str, Any]:
return jwt.decode(token, ADMIN_JWT_SECRET, algorithms=["HS256"]) 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( async def require_admin(
request: Request, request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
@@ -73,13 +81,13 @@ async def require_admin(
if not admin_configured(): if not admin_configured():
raise HTTPException(status_code=503, detail="Admin access is not configured") raise HTTPException(status_code=503, detail="Admin access is not configured")
if credentials is None or credentials.scheme.lower() != "bearer": 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: try:
payload = decode_access_token(credentials.credentials) payload = decode_access_token(credentials.credentials)
except JWTError as exc: 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": 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 return payload

View File

@@ -2,7 +2,7 @@ import os
from pathlib import Path from pathlib import Path
APP_NAME = "HexaWetter" APP_NAME = "HexaWetter"
APP_VERSION = "1.1.0" APP_VERSION = "1.2.0"
DATA_DIR = Path(os.getenv("DATA_DIR", "/data")) DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
RAW_RADAR_DIR = DATA_DIR / "raw" / "radar" 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")) 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_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH", "") ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH", "")
ADMIN_JWT_SECRET = os.getenv("ADMIN_JWT_SECRET", "") ADMIN_JWT_SECRET = os.getenv("ADMIN_JWT_SECRET", "")

View File

@@ -200,7 +200,7 @@ async def list_rendered_radar(product: str, limit: int = 24) -> list[dict[str, A
result.append( result.append(
{ {
"filename": row["filename"], "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, "bounds": bounds,
"modified_at": row["modified_at"].isoformat() if row["modified_at"] else None, "modified_at": row["modified_at"].isoformat() if row["modified_at"] else None,
"rendered_at": row["updated_at"].isoformat() if row["updated_at"] else None, "rendered_at": row["updated_at"].isoformat() if row["updated_at"] else None,

View File

@@ -1,52 +1,45 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import re
import time import time
from collections import defaultdict from collections import defaultdict
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone from datetime import datetime, timezone
from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
from fastapi import FastAPI, HTTPException, Query, Request from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware 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 pydantic import BaseModel, Field
from app.auth import admin_configured from app.cache import close_redis, init_redis
from app.cache import close_redis, init_redis, redis_available
from app.config import ( from app.config import (
APP_NAME, APP_NAME,
APP_VERSION, APP_VERSION,
BRIGHTSKY_BASE_URL,
DEFAULT_LAT, DEFAULT_LAT,
DEFAULT_LON, DEFAULT_LON,
DEFAULT_PLACE, DEFAULT_PLACE,
DWD_RADAR_BASE_URL,
DWD_WMS_URL, DWD_WMS_URL,
OPEN_METEO_BASE_URL,
RADAR_PRODUCT, RADAR_PRODUCT,
RADAR_RETENTION_HOURS,
RATE_LIMIT_PER_MINUTE, RATE_LIMIT_PER_MINUTE,
RENDERED_RADAR_DIR,
RAW_RADAR_DIR, RAW_RADAR_DIR,
RENDERED_RADAR_DIR,
) )
from app.database import ( from app.database import close_db, init_db
close_db, from app.http_client import close_http_client, init_http_client
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.services.dashboard import build_dashboard 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.geocoding import reverse_geocode, search_places
from app.services.health import check_sources
from app.routes.admin import router as admin_router 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.warnings import fetch_warnings_for_location
from app.services.weather import get_forecast, get_observations from app.services.weather import get_forecast, get_observations
from app.services.wms import fetch_wms_time_steps from app.services.wms import fetch_wms_time_steps
@@ -74,6 +67,7 @@ class FavoriteLocation(BaseModel):
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
validate_security_config()
await init_http_client() await init_http_client()
await init_db() await init_db()
await init_redis() await init_redis()
@@ -87,14 +81,32 @@ async def lifespan(_: FastAPI):
app = FastAPI(title=APP_NAME, version=APP_VERSION, lifespan=lifespan) app = FastAPI(title=APP_NAME, version=APP_VERSION, lifespan=lifespan)
app.include_router(admin_router) app.include_router(admin_router)
_hosts = list(trusted_hosts())
if _hosts:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=_hosts)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=cors_allowed_origins(),
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "OPTIONS"],
allow_headers=["*"], 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") @app.middleware("http")
async def security_headers_middleware(request: Request, call_next): async def security_headers_middleware(request: Request, call_next):
response = await call_next(request) 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["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "geolocation=(self)" 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 return response
@@ -131,20 +146,6 @@ async def health() -> dict[str, Any]:
"service": APP_NAME, "service": APP_NAME,
"version": APP_VERSION, "version": APP_VERSION,
"time_utc": utc_now().isoformat(), "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 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") @app.get("/api/location/default")
async def default_location() -> Location: async def default_location() -> Location:
return 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 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") @app.get("/api/radar/wms")
async def radar_wms() -> dict[str, Any]: async def radar_wms() -> dict[str, Any]:
return { return {
@@ -391,14 +298,3 @@ async def radar_wms_times(
raise HTTPException(status_code=502, detail=f"WMS time dimension unavailable: {exc}") from exc 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.",
}

View File

@@ -1,11 +1,14 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import os import os
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any 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 pydantic import BaseModel, Field
from app.auth import ( from app.auth import (
@@ -37,8 +40,10 @@ from app.config import (
RAW_RADAR_DIR, RAW_RADAR_DIR,
RENDERED_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.health import check_sources
from app.services.radar_sync import sync_radar_files
router = APIRouter(prefix="/api/admin", tags=["admin"]) router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -87,7 +92,7 @@ def _public_config() -> dict[str, Any]:
@router.get("/status") @router.get("/status")
async def admin_status_public() -> dict[str, Any]: async def admin_status_public() -> dict[str, Any]:
return {"admin_enabled": admin_configured(), "version": APP_VERSION} return {"admin_enabled": admin_configured()}
@router.post("/login") @router.post("/login")
@@ -97,6 +102,7 @@ async def admin_login(request: Request, payload: LoginRequest) -> dict[str, Any]
check_login_rate_limit(request) check_login_rate_limit(request)
if payload.username != ADMIN_USERNAME or not verify_password(payload.password, os.getenv("ADMIN_PASSWORD_HASH", "")): if payload.username != ADMIN_USERNAME or not verify_password(payload.password, os.getenv("ADMIN_PASSWORD_HASH", "")):
record_failed_login(request) record_failed_login(request)
await asyncio.sleep(1)
raise HTTPException(status_code=401, detail="Invalid credentials") raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token(payload.username) token = create_access_token(payload.username)
return { 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") @router.post("/cache/clear")
async def admin_clear_cache(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]: async def admin_clear_cache(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]:
from app.cache import flush_cache from app.cache import flush_cache

70
api/app/security.py Normal file
View 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

View 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,
}

View File

@@ -4,6 +4,7 @@ import re
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from app.cache import cache_get, cache_set
from app.http_client import get_client from app.http_client import get_client
from app.config import CACHE_TTL_WMS_TIMES, DWD_WMS_URL from app.config import CACHE_TTL_WMS_TIMES, DWD_WMS_URL

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -42,7 +42,7 @@ services:
volumes: volumes:
- ./data:/data - ./data:/data
ports: ports:
- "${API_PORT:-8081}:8080" - "127.0.0.1:${API_PORT:-8081}:8080"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -53,11 +53,13 @@ services:
image: nginx:1.27-alpine image: nginx:1.27-alpine
container_name: hexawetter-frontend container_name: hexawetter-frontend
restart: unless-stopped restart: unless-stopped
environment:
API_KEY: ${API_KEY:-}
depends_on: depends_on:
- api - api
volumes: volumes:
- ./frontend:/usr/share/nginx/html:ro - ./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: ports:
- "${WEB_PORT:-8080}:80" - "${WEB_PORT:-8080}:80"

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>Admin HexaWetter</title> <title>Admin HexaWetter</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
@@ -47,6 +48,7 @@
</div> </div>
<div style="display:flex;gap:8px;flex-wrap:wrap"> <div style="display:flex;gap:8px;flex-wrap:wrap">
<button id="refreshBtn" class="btn btn-secondary">Aktualisieren</button> <button id="refreshBtn" class="btn btn-secondary">Aktualisieren</button>
<button id="syncRadarBtn" class="btn btn-secondary">Radar syncen</button>
<button id="clearCacheBtn" class="btn btn-secondary">Redis-Cache leeren</button> <button id="clearCacheBtn" class="btn btn-secondary">Redis-Cache leeren</button>
</div> </div>
</div> </div>
@@ -80,6 +82,20 @@
</form> </form>
<p class="muted" id="settingsMsg" style="margin-top:10px"></p> <p class="muted" id="settingsMsg" style="margin-top:10px"></p>
</section> </section>
<section class="glass-card">
<div class="section-head">
<div><h2>Radar-Rohdaten</h2><p class="muted no-margin">Lokale DWD-Dateien und gerenderte Frames</p></div>
<span class="muted" id="renderedCount"></span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Datei</th><th>Größe</th><th>Geändert</th><th></th></tr></thead>
<tbody id="radarFiles"></tbody>
</table>
</div>
<div class="rendered-grid" id="renderedFrames" style="margin-top:16px"></div>
</section>
</section> </section>
</div> </div>
</main> </main>

View File

@@ -35,6 +35,67 @@ function formatBytes(bytes) {
return `${v.toFixed(u === 0 ? 0 : 1)} ${units[u]}`; 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) => `
<tr>
<td>${file.name}</td>
<td>${formatBytes(file.size_bytes)}</td>
<td>${new Date(file.modified_utc).toLocaleString("de-DE")}</td>
<td><button type="button" class="btn btn-secondary" data-download="${file.download_url}" data-name="${file.name}">Download</button></td>
</tr>
`).join("") : `<tr><td colspan="4" class="muted">Noch keine lokalen Radar-Dateien.</td></tr>`;
el("radarFiles").querySelectorAll("[data-download]").forEach((btn) => {
btn.addEventListener("click", () => downloadAdminFile(btn.dataset.download, btn.dataset.name));
});
} catch {
el("radarFiles").innerHTML = `<tr><td colspan="4" class="muted">Rohdaten nicht verfügbar</td></tr>`;
}
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 = `<p class="muted">Noch keine gerenderten Frames.</p>`;
return;
}
el("renderedFrames").innerHTML = frames.map((f, i) => `
<figure class="rendered-frame" id="renderedFrame${i}">
<div class="muted" style="padding:40px;text-align:center">Lade…</div>
<figcaption>${f.filename}</figcaption>
</figure>
`).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 = `<img src="${url}" alt="Radar ${f.filename}" loading="lazy" /><figcaption>${f.filename}</figcaption>`;
}));
} catch {
el("renderedCount").textContent = "";
el("renderedFrames").innerHTML = `<p class="muted">Renderer nicht verfügbar.</p>`;
}
}
function showLogin() { function showLogin() {
el("loginSection").classList.remove("admin-hidden"); el("loginSection").classList.remove("admin-hidden");
el("dashboardSection").classList.add("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("setCacheObs").value = overrides.cache_ttl_observations ?? defaults.cache_ttl.observations;
el("setCacheWarn").value = overrides.cache_ttl_warnings ?? defaults.cache_ttl.warnings; el("setCacheWarn").value = overrides.cache_ttl_warnings ?? defaults.cache_ttl.warnings;
el("setRadarRetention").value = overrides.radar_retention_hours ?? defaults.radar_retention_hours; el("setRadarRetention").value = overrides.radar_retention_hours ?? defaults.radar_retention_hours;
await loadRadarData();
} }
el("loginForm").addEventListener("submit", async (e) => { el("loginForm").addEventListener("submit", async (e) => {
@@ -124,6 +186,23 @@ el("clearCacheBtn").addEventListener("click", async () => {
await loadDashboard(); 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) => { el("settingsForm").addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const payload = { const payload = {

View File

@@ -10,12 +10,11 @@ let layerControl = null;
let osmLayer = null; let osmLayer = null;
let radarLayer = null; let radarLayer = null;
let warningLayer = null; let warningLayer = null;
let localRadarOverlay = null;
let radarTimes = []; let radarTimes = [];
let radarFrameIndex = 0; let radarFrameIndex = 0;
let radarPlayTimer = null; let radarPlayTimer = null;
let radarPlaying = false; let radarPlaying = false;
let renderedFrames = []; let hourlyChart = null;
let settings = loadSettings(); let settings = loadSettings();
let searchTimer = null; let searchTimer = null;
@@ -44,14 +43,6 @@ function saveFavorites(items) {
localStorage.setItem(FAV_KEY, JSON.stringify(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) { function weatherText(code) {
const map = { const map = {
0: "Klar", 1: "Überwiegend klar", 2: "Teils bewölkt", 3: "Bewölkt", 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`; 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 = {}) { async function api(path, options = {}) {
const res = await fetch(path, options); const res = await fetch(path, options);
if (!res.ok) { 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) { function applyDashboard(dash) {
const loc = getLocation(); const loc = getLocation();
if (dash.forecast?.data) { if (dash.forecast?.data) {
@@ -207,18 +322,6 @@ function applyWarningsData(data) {
`).join("") : `<p class="muted">${localOnly ? "Keine aktiven Warnungen für deinen Standort." : "Keine Warnungen verfügbar."}</p>`; `).join("") : `<p class="muted">${localOnly ? "Keine aktiven Warnungen für deinen Standort." : "Keine Warnungen verfügbar."}</p>`;
} }
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) => `
<div class="source-status ${s.status}">
<strong>${s.name}</strong>
<span>${s.status}${s.latency_ms ? ` · ${s.latency_ms} ms` : ""}</span>
${s.error ? `<small>${s.error}</small>` : ""}
</div>
`).join("");
}
async function loadForecast() { async function loadForecast() {
const loc = getLocation(); const loc = getLocation();
const data = await api(`/api/forecast?lat=${loc.lat}&lon=${loc.lon}`); const data = await api(`/api/forecast?lat=${loc.lat}&lon=${loc.lon}`);
@@ -270,6 +373,7 @@ function renderHourly(hourly) {
<td>${convertWind(hourly.wind_gusts_10m[idx] ?? 0)}</td> <td>${convertWind(hourly.wind_gusts_10m[idx] ?? 0)}</td>
</tr> </tr>
`).join(""); `).join("");
renderHourlyChart(hourly);
} }
function renderHourStrip(hourly) { function renderHourStrip(hourly) {
@@ -306,33 +410,7 @@ async function loadWarnings() {
const localOnly = el("warningsLocalOnly")?.checked ?? true; const localOnly = el("warningsLocalOnly")?.checked ?? true;
try { try {
const data = await api(`/api/warnings?lat=${loc.lat}&lon=${loc.lon}&local_only=${localOnly}`); const data = await api(`/api/warnings?lat=${loc.lat}&lon=${loc.lon}&local_only=${localOnly}`);
const warnings = data.warnings || []; applyWarningsData(data);
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) => `<strong>${a.name}</strong> (${a.warncell_id})`).join(" · ")}`;
} else {
warncellEl.classList.add("hidden");
warncellEl.innerHTML = "";
}
el("warningsList").innerHTML = warnings.length ? warnings.map((w) => `
<article class="warning-card ${warnLevelClass(w.level)}">
<div class="warning-head">
<strong>${w.headline || w.event}</strong>
<span class="warn-badge">Level ${w.level} · ${w.level_label}</span>
</div>
<p class="muted">${w.region_name || ""} · ${w.state || ""}</p>
<p>${(w.description || "").replace(/\n/g, "<br>")}</p>
${w.instruction ? `<p class="instruction">${w.instruction}</p>` : ""}
<small class="muted">Gültig: ${w.start ? formatDateTime(w.start) : ""} ${w.end ? formatDateTime(w.end) : ""}</small>
</article>
`).join("") : `<p class="muted">${localOnly ? "Keine aktiven Warnungen für deinen Standort." : "Keine Warnungen verfügbar."}</p>`;
} catch (err) { } catch (err) {
el("warnCount").textContent = ""; el("warnCount").textContent = "";
el("warnMeta").textContent = err.message; 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) => `
<tr><td>${file.name}</td><td>${formatBytes(file.size_bytes)}</td>
<td>${new Date(file.modified_utc).toLocaleString("de-DE")}</td>
<td><a href="${file.download_url}">Download</a></td></tr>
`).join("") : `<tr><td colspan="4" class="muted">Noch keine lokalen Radar-Dateien.</td></tr>`;
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) => `
<figure class="rendered-frame">
<img src="${f.image_url}" alt="Radar ${f.filename}" loading="lazy" />
<figcaption>${f.filename}</figcaption>
</figure>
`).join("") : `<p class="muted">Noch keine gerenderten Frames. Renderer-Worker abwarten.</p>`;
} catch {
el("renderedCount").textContent = "";
el("renderedFrames").innerHTML = `<p class="muted">Renderer nicht verfügbar.</p>`;
}
}
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]) => `
<div class="source-item"><span>${key}</span><code>${String(value)}</code></div>
`).join("");
} catch (err) {
el("sourceList").innerHTML = `<p class="muted">${err.message}</p>`;
}
}
function setRadarTime(index) { function setRadarTime(index) {
if (!radarTimes.length) return; if (!radarTimes.length) return;
radarFrameIndex = Math.max(0, Math.min(index, radarTimes.length - 1)); radarFrameIndex = Math.max(0, Math.min(index, radarTimes.length - 1));
@@ -399,24 +427,11 @@ function setRadarTime(index) {
el("radarTimeLabel").textContent = formatDateTime(time); el("radarTimeLabel").textContent = formatDateTime(time);
el("radarFrameInfo").textContent = `Frame ${radarFrameIndex + 1} / ${radarTimes.length}`; el("radarFrameInfo").textContent = `Frame ${radarFrameIndex + 1} / ${radarTimes.length}`;
if (el("useLocalRadar").checked && renderedFrames.length) { if (radarLayer) {
showLocalRadarFrame(radarFrameIndex);
} else if (radarLayer) {
radarLayer.setParams({ TIME: time, _cache: Date.now() }); 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() { async function loadRadarTimes() {
const layer = radarLayer?.wmsParams?.layers || "dwd:Niederschlagsradar"; const layer = radarLayer?.wmsParams?.layers || "dwd:Niederschlagsradar";
const minutes = settings.radarMinutes || 120; 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)); document.querySelectorAll(".view").forEach((view) => view.classList.toggle("active", view.dataset.view === name));
if (name === "radar") initMap().then(() => setTimeout(() => map?.invalidateSize(), 150)); if (name === "radar") initMap().then(() => setTimeout(() => map?.invalidateSize(), 150));
if (name === "warnings") loadWarnings(); 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() { async function loadAll() {
await loadHealth(); loadHealth();
await Promise.allSettled([loadForecast(), loadObservations(), loadRadarFiles(), loadSources(), loadWarnings()]); 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(); updateMapLocation();
if (map) loadRadarTimes(); if (map) loadRadarTimes();
} }
@@ -614,20 +651,17 @@ async function init() {
await loadDefaultLocation(); await loadDefaultLocation();
renderFavorites(); renderFavorites();
await loadAll(); await loadAll();
setInterval(loadHealth, 30000); setInterval(loadHealth, 60000);
} }
document.querySelectorAll(".tab").forEach((tab) => tab.addEventListener("click", () => activateView(tab.dataset.view))); document.querySelectorAll(".tab").forEach((tab) => tab.addEventListener("click", () => activateView(tab.dataset.view)));
el("loadBtn").addEventListener("click", loadAll); el("loadBtn").addEventListener("click", loadAll);
el("syncRadarBtn").addEventListener("click", syncRadar);
el("centerMapBtn").addEventListener("click", updateMapLocation); el("centerMapBtn").addEventListener("click", updateMapLocation);
el("reloadRadarLayersBtn").addEventListener("click", reloadRadarLayers); el("reloadRadarLayersBtn").addEventListener("click", reloadRadarLayers);
el("favBtn").addEventListener("click", addFavorite); el("favBtn").addEventListener("click", addFavorite);
el("geoBtn").addEventListener("click", useGeolocation); el("geoBtn").addEventListener("click", useGeolocation);
el("warningsLocalOnly")?.addEventListener("change", loadWarnings); el("warningsLocalOnly")?.addEventListener("change", loadWarnings);
el("showWarningsOnMap")?.addEventListener("change", toggleWarningsOnMap); el("showWarningsOnMap")?.addEventListener("change", toggleWarningsOnMap);
el("useLocalRadar")?.addEventListener("change", () => setRadarTime(radarFrameIndex));
el("radarPlayBtn").addEventListener("click", toggleRadarPlay); el("radarPlayBtn").addEventListener("click", toggleRadarPlay);
el("radarPrevBtn").addEventListener("click", () => setRadarTime(radarFrameIndex - 1)); el("radarPrevBtn").addEventListener("click", () => setRadarTime(radarFrameIndex - 1));
el("radarNextBtn").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"; settings.theme = settings.theme === "dark" ? "light" : "dark";
saveSettings(); saveSettings();
applyTheme(); applyTheme();
if (latestForecast?.data?.hourly) renderHourlyChart(latestForecast.data.hourly);
}); });
["unitTemp", "unitWind", "radarMinutes"].forEach((id) => { ["unitTemp", "unitWind", "radarMinutes"].forEach((id) => {
@@ -656,7 +691,12 @@ el("themeToggle").addEventListener("click", () => {
if (id === "unitWind") settings.unitWind = e.target.value; if (id === "unitWind") settings.unitWind = e.target.value;
if (id === "radarMinutes") settings.radarMinutes = Number(e.target.value); if (id === "radarMinutes") settings.radarMinutes = Number(e.target.value);
saveSettings(); 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(); if (id === "radarMinutes" && map) loadRadarTimes();
}); });
}); });

View File

@@ -73,7 +73,6 @@
<button class="tab" data-view="warnings">Warnungen</button> <button class="tab" data-view="warnings">Warnungen</button>
<button class="tab" data-view="hourly">Stunden</button> <button class="tab" data-view="hourly">Stunden</button>
<button class="tab" data-view="daily">7 Tage</button> <button class="tab" data-view="daily">7 Tage</button>
<button class="tab" data-view="raw">Rohdaten</button>
<button class="tab" data-view="settings">Einstellungen</button> <button class="tab" data-view="settings">Einstellungen</button>
</nav> </nav>
@@ -92,10 +91,6 @@
<div class="section-head"><h2>Kurzvorschau</h2><span class="muted">Nächste Stunden</span></div> <div class="section-head"><h2>Kurzvorschau</h2><span class="muted">Nächste Stunden</span></div>
<div class="hour-strip" id="hourStrip"></div> <div class="hour-strip" id="hourStrip"></div>
</section> </section>
<section class="glass-card">
<div class="section-head"><h2>Datenquellen-Status</h2><span class="muted" id="sourceStatusSummary">Prüfe…</span></div>
<div class="source-status-grid" id="sourceStatusGrid"></div>
</section>
</section> </section>
<section class="view" data-view="radar"> <section class="view" data-view="radar">
@@ -116,7 +111,6 @@
<input id="radarSlider" type="range" min="0" max="0" value="0" /> <input id="radarSlider" type="range" min="0" max="0" value="0" />
</div> </div>
<div class="timeline-meta"> <div class="timeline-meta">
<label class="inline-check"><input type="checkbox" id="useLocalRadar" /> Lokale Render-Frames</label>
<label class="inline-check"><input type="checkbox" id="showWarningsOnMap" checked /> Warnungen</label> <label class="inline-check"><input type="checkbox" id="showWarningsOnMap" checked /> Warnungen</label>
<span class="muted" id="radarFrameInfo"></span> <span class="muted" id="radarFrameInfo"></span>
</div> </div>
@@ -144,8 +138,21 @@
<section class="view" data-view="hourly"> <section class="view" data-view="hourly">
<section class="glass-card"> <section class="glass-card">
<div class="section-head"><h2>Stundenansicht</h2><span class="muted">48 Stunden</span></div> <div class="section-head">
<div class="table-wrap"><table><thead><tr><th>Zeit</th><th>Wetter</th><th>Temp.</th><th>Regen</th><th>Wolken</th><th>Wind</th><th>Böen</th></tr></thead><tbody id="hourlyTable"></tbody></table></div> <h2>Stundenansicht</h2>
<span class="muted">48 Stunden · Temperatur, Regen & Wind</span>
</div>
<div class="hourly-chart-wrap">
<canvas id="hourlyChart" aria-label="Stundenverlauf Temperatur, Regen und Wind"></canvas>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>Zeit</th><th>Wetter</th><th>Temp.</th><th>Regen</th><th>Wolken</th><th>Wind</th><th>Böen</th></tr>
</thead>
<tbody id="hourlyTable"></tbody>
</table>
</div>
</section> </section>
</section> </section>
@@ -156,24 +163,6 @@
</section> </section>
</section> </section>
<section class="view" data-view="raw">
<section class="glass-card">
<div class="section-head">
<div><h2>Radar-Rohdaten</h2><p class="muted no-margin">DWD OpenData · lokaler Renderer</p></div>
<button id="syncRadarBtn" class="btn btn-secondary">Sync starten</button>
</div>
<div class="table-wrap"><table><thead><tr><th>Datei</th><th>Größe</th><th>Geändert</th><th>Download</th></tr></thead><tbody id="radarFiles"></tbody></table></div>
</section>
<section class="glass-card">
<div class="section-head"><h2>Gerenderte Frames</h2><span class="muted" id="renderedCount"></span></div>
<div class="rendered-grid" id="renderedFrames"></div>
</section>
<section class="glass-card">
<div class="section-head"><h2>Datenquellen</h2></div>
<div class="source-list" id="sourceList"></div>
</section>
</section>
<section class="view" data-view="settings"> <section class="view" data-view="settings">
<section class="glass-card"> <section class="glass-card">
<h2>Einstellungen</h2> <h2>Einstellungen</h2>
@@ -213,6 +202,7 @@
</footer> </footer>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

View File

@@ -307,6 +307,19 @@ html[data-theme="light"] .search-results { background: #fff; }
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px; 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; } .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 { 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)); } .bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--accent-color-2), var(--primary-color)); }

View File

@@ -14,6 +14,7 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-API-Key ${API_KEY};
proxy_connect_timeout 10s; proxy_connect_timeout 10s;
proxy_read_timeout 120s; proxy_read_timeout 120s;
} }

View File

@@ -1,38 +1,10 @@
#!/usr/bin/env python3 #!/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 from __future__ import annotations
import argparse import runpy
import secrets import sys
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.")
if __name__ == "__main__": if __name__ == "__main__":
main() sys.argv[0] = "scripts/setup_secrets.py"
runpy.run_path("scripts/setup_secrets.py", run_name="__main__")

50
scripts/setup_secrets.py Normal file
View File

@@ -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()