HexaWetter v1.2.0: Security, Admin, Radar, UI
54
.env
@@ -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
|
||||
12
.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=
|
||||
|
||||
18
.gitignore
vendored
Normal 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
|
||||
17
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)
|
||||
|
||||
|
||||
@@ -66,6 +66,14 @@ def decode_access_token(token: str) -> dict[str, Any]:
|
||||
return jwt.decode(token, ADMIN_JWT_SECRET, algorithms=["HS256"])
|
||||
|
||||
|
||||
def _unauthorized(detail: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=detail,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
async def require_admin(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
@@ -73,13 +81,13 @@ async def require_admin(
|
||||
if not admin_configured():
|
||||
raise HTTPException(status_code=503, detail="Admin access is not configured")
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
|
||||
raise _unauthorized("Missing bearer token")
|
||||
try:
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
except JWTError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") from exc
|
||||
raise _unauthorized("Invalid or expired token") from exc
|
||||
if payload.get("sub") != ADMIN_USERNAME or payload.get("scope") != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token scope")
|
||||
raise _unauthorized("Invalid token scope")
|
||||
return payload
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
APP_NAME = "HexaWetter"
|
||||
APP_VERSION = "1.1.0"
|
||||
APP_VERSION = "1.2.0"
|
||||
|
||||
DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
|
||||
RAW_RADAR_DIR = DATA_DIR / "raw" / "radar"
|
||||
@@ -42,6 +42,15 @@ CACHE_TTL_DASHBOARD = int(os.getenv("CACHE_TTL_DASHBOARD", "60"))
|
||||
|
||||
RATE_LIMIT_PER_MINUTE = int(os.getenv("RATE_LIMIT_PER_MINUTE", "120"))
|
||||
|
||||
API_KEY = os.getenv("API_KEY", "")
|
||||
REQUIRE_API_KEY = os.getenv("REQUIRE_API_KEY", "true").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
_allowed_origins = os.getenv("ALLOWED_ORIGINS", "").strip()
|
||||
ALLOWED_ORIGINS = tuple(o.strip() for o in _allowed_origins.split(",") if o.strip())
|
||||
|
||||
_allowed_hosts = os.getenv("ALLOWED_HOSTS", "").strip()
|
||||
ALLOWED_HOSTS = tuple(h.strip() for h in _allowed_hosts.split(",") if h.strip())
|
||||
|
||||
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
|
||||
ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH", "")
|
||||
ADMIN_JWT_SECRET = os.getenv("ADMIN_JWT_SECRET", "")
|
||||
|
||||
@@ -200,7 +200,7 @@ async def list_rendered_radar(product: str, limit: int = 24) -> list[dict[str, A
|
||||
result.append(
|
||||
{
|
||||
"filename": row["filename"],
|
||||
"image_url": f"/api/radar/rendered/{product}/{row['filename']}.png",
|
||||
"image_url": f"/api/admin/radar/rendered/{product}/{row['filename']}.png",
|
||||
"bounds": bounds,
|
||||
"modified_at": row["modified_at"].isoformat() if row["modified_at"] else None,
|
||||
"rendered_at": row["updated_at"].isoformat() if row["updated_at"] else None,
|
||||
|
||||
184
api/app/main.py
@@ -1,52 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.auth import admin_configured
|
||||
from app.cache import close_redis, init_redis, redis_available
|
||||
from app.cache import close_redis, init_redis
|
||||
from app.config import (
|
||||
APP_NAME,
|
||||
APP_VERSION,
|
||||
BRIGHTSKY_BASE_URL,
|
||||
DEFAULT_LAT,
|
||||
DEFAULT_LON,
|
||||
DEFAULT_PLACE,
|
||||
DWD_RADAR_BASE_URL,
|
||||
DWD_WMS_URL,
|
||||
OPEN_METEO_BASE_URL,
|
||||
RADAR_PRODUCT,
|
||||
RADAR_RETENTION_HOURS,
|
||||
RATE_LIMIT_PER_MINUTE,
|
||||
RENDERED_RADAR_DIR,
|
||||
RAW_RADAR_DIR,
|
||||
RENDERED_RADAR_DIR,
|
||||
)
|
||||
from app.database import (
|
||||
close_db,
|
||||
init_db,
|
||||
list_rendered_radar,
|
||||
pool_available,
|
||||
upsert_radar_meta,
|
||||
)
|
||||
from app.http_client import close_http_client, get_client, init_http_client
|
||||
from app.database import close_db, init_db
|
||||
from app.http_client import close_http_client, init_http_client
|
||||
from app.services.dashboard import build_dashboard
|
||||
from app.services.dwd_radar import RADAR_FILE_RE, fetch_radar_index
|
||||
from app.services.dwd_radar import fetch_radar_index
|
||||
from app.services.geocoding import reverse_geocode, search_places
|
||||
from app.services.health import check_sources
|
||||
from app.routes.admin import router as admin_router
|
||||
from app.security import (
|
||||
cors_allowed_origins,
|
||||
is_api_key_exempt,
|
||||
trusted_hosts,
|
||||
validate_security_config,
|
||||
verify_request_api_key,
|
||||
)
|
||||
from app.services.warnings import fetch_warnings_for_location
|
||||
from app.services.weather import get_forecast, get_observations
|
||||
from app.services.wms import fetch_wms_time_steps
|
||||
@@ -74,6 +67,7 @@ class FavoriteLocation(BaseModel):
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
validate_security_config()
|
||||
await init_http_client()
|
||||
await init_db()
|
||||
await init_redis()
|
||||
@@ -87,14 +81,32 @@ async def lifespan(_: FastAPI):
|
||||
|
||||
app = FastAPI(title=APP_NAME, version=APP_VERSION, lifespan=lifespan)
|
||||
app.include_router(admin_router)
|
||||
|
||||
_hosts = list(trusted_hosts())
|
||||
if _hosts:
|
||||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=_hosts)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_origins=cors_allowed_origins(),
|
||||
allow_methods=["GET", "POST", "PUT", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type", "X-API-Key"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def api_key_middleware(request: Request, call_next):
|
||||
path = request.url.path
|
||||
if path.startswith("/api/") and not is_api_key_exempt(path):
|
||||
if not verify_request_api_key(request):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "Invalid or missing API key"},
|
||||
headers={"WWW-Authenticate": "ApiKey"},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers_middleware(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
@@ -102,6 +114,9 @@ async def security_headers_middleware(request: Request, call_next):
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Permissions-Policy"] = "geolocation=(self)"
|
||||
if path := request.url.path:
|
||||
if path.startswith("/api/admin") or path.endswith("admin.html"):
|
||||
response.headers["X-Robots-Tag"] = "noindex, nofollow"
|
||||
return response
|
||||
|
||||
|
||||
@@ -131,20 +146,6 @@ async def health() -> dict[str, Any]:
|
||||
"service": APP_NAME,
|
||||
"version": APP_VERSION,
|
||||
"time_utc": utc_now().isoformat(),
|
||||
"admin_enabled": admin_configured(),
|
||||
"postgres": pool_available(),
|
||||
"redis": redis_available(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/health/sources")
|
||||
async def health_sources() -> dict[str, Any]:
|
||||
sources = await check_sources()
|
||||
degraded = [s for s in sources if s.get("status") not in ("ok", "disabled")]
|
||||
return {
|
||||
"status": "degraded" if degraded else "ok",
|
||||
"time_utc": utc_now().isoformat(),
|
||||
"sources": sources,
|
||||
}
|
||||
|
||||
|
||||
@@ -162,18 +163,6 @@ async def dashboard(
|
||||
raise HTTPException(status_code=502, detail=f"Dashboard error: {exc}") from exc
|
||||
|
||||
|
||||
def cleanup_old_radar_files() -> None:
|
||||
cutoff = utc_now() - timedelta(hours=RADAR_RETENTION_HOURS)
|
||||
if not RAW_RADAR_DIR.exists():
|
||||
return
|
||||
for path in RAW_RADAR_DIR.rglob("*"):
|
||||
if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name):
|
||||
continue
|
||||
modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
|
||||
if modified < cutoff:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@app.get("/api/location/default")
|
||||
async def default_location() -> Location:
|
||||
return Location()
|
||||
@@ -247,88 +236,6 @@ async def radar_latest(
|
||||
raise HTTPException(status_code=502, detail=f"DWD radar index unavailable: {exc}") from exc
|
||||
|
||||
|
||||
@app.post("/api/radar/sync")
|
||||
async def radar_sync(
|
||||
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
|
||||
limit: int = Query(6, ge=1, le=50),
|
||||
) -> dict[str, Any]:
|
||||
latest = await radar_latest(product=product, limit=limit)
|
||||
target_dir = RAW_RADAR_DIR / "composite" / product
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
downloaded = []
|
||||
skipped = []
|
||||
|
||||
client = get_client()
|
||||
for item in latest["files"]:
|
||||
out = target_dir / item["name"]
|
||||
if out.exists() and out.stat().st_size > 0:
|
||||
skipped.append(item["name"])
|
||||
continue
|
||||
response = await client.get(item["url"], timeout=90.0)
|
||||
response.raise_for_status()
|
||||
out.write_bytes(response.content)
|
||||
downloaded.append(item["name"])
|
||||
modified = datetime.fromtimestamp(out.stat().st_mtime, timezone.utc)
|
||||
await upsert_radar_meta(product, item["name"], out.stat().st_size, modified, "pending")
|
||||
|
||||
cleanup_old_radar_files()
|
||||
return {"product": product, "target_dir": str(target_dir), "downloaded": downloaded, "skipped": skipped}
|
||||
|
||||
|
||||
@app.get("/api/radar/files")
|
||||
async def radar_files(
|
||||
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
|
||||
) -> dict[str, Any]:
|
||||
target_dir = RAW_RADAR_DIR / "composite" / product
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
files = []
|
||||
for path in sorted(target_dir.iterdir()):
|
||||
if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name):
|
||||
continue
|
||||
modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
|
||||
files.append(
|
||||
{
|
||||
"name": path.name,
|
||||
"size_bytes": path.stat().st_size,
|
||||
"modified_utc": modified.isoformat(),
|
||||
"download_url": f"/api/radar/files/{product}/{path.name}",
|
||||
}
|
||||
)
|
||||
return {"product": product, "path": str(target_dir), "count": len(files), "files": files}
|
||||
|
||||
|
||||
@app.get("/api/radar/files/{product}/{filename}")
|
||||
async def radar_file_download(product: str, filename: str):
|
||||
if not re.fullmatch(r"[a-z0-9_-]+", product, re.IGNORECASE):
|
||||
raise HTTPException(status_code=400, detail="Invalid product")
|
||||
if not RADAR_FILE_RE.fullmatch(filename):
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
path = RAW_RADAR_DIR / "composite" / product / filename
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
media_type = "application/x-tar" if filename.lower().endswith(".tar") else "application/octet-stream"
|
||||
return FileResponse(path, filename=filename, media_type=media_type)
|
||||
|
||||
|
||||
@app.get("/api/radar/rendered")
|
||||
async def radar_rendered_list(
|
||||
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
|
||||
limit: int = Query(24, ge=1, le=100),
|
||||
) -> dict[str, Any]:
|
||||
frames = await list_rendered_radar(product, limit=limit)
|
||||
return {"product": product, "count": len(frames), "frames": frames}
|
||||
|
||||
|
||||
@app.get("/api/radar/rendered/{product}/{filename}.png")
|
||||
async def radar_rendered_image(product: str, filename: str):
|
||||
if not re.fullmatch(r"[a-z0-9_.-]+", filename, re.IGNORECASE):
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
path = RENDERED_RADAR_DIR / product / f"{filename}.png"
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="Rendered image not found")
|
||||
return FileResponse(path, media_type="image/png")
|
||||
|
||||
|
||||
@app.get("/api/radar/wms")
|
||||
async def radar_wms() -> dict[str, Any]:
|
||||
return {
|
||||
@@ -391,14 +298,3 @@ async def radar_wms_times(
|
||||
raise HTTPException(status_code=502, detail=f"WMS time dimension unavailable: {exc}") from exc
|
||||
|
||||
|
||||
@app.get("/api/sources")
|
||||
async def sources() -> dict[str, Any]:
|
||||
return {
|
||||
"note": "HexaWetter kann externe APIs nutzen oder über docker-compose.selfhosted.yml eigene Instanzen anbinden.",
|
||||
"open_meteo_base_url": OPEN_METEO_BASE_URL,
|
||||
"brightsky_base_url": BRIGHTSKY_BASE_URL,
|
||||
"dwd_radar_base_url": DWD_RADAR_BASE_URL,
|
||||
"dwd_wms_url": DWD_WMS_URL,
|
||||
"dwd_attribution": "Datenbasis: Deutscher Wetterdienst (DWD), Open Data.",
|
||||
"osm_attribution": "Kartenbasis: OpenStreetMap-Mitwirkende.",
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.auth import (
|
||||
@@ -37,8 +40,10 @@ from app.config import (
|
||||
RAW_RADAR_DIR,
|
||||
RENDERED_RADAR_DIR,
|
||||
)
|
||||
from app.database import pool_available
|
||||
from app.database import list_rendered_radar, pool_available
|
||||
from app.services.dwd_radar import RADAR_FILE_RE
|
||||
from app.services.health import check_sources
|
||||
from app.services.radar_sync import sync_radar_files
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
@@ -87,7 +92,7 @@ def _public_config() -> dict[str, Any]:
|
||||
|
||||
@router.get("/status")
|
||||
async def admin_status_public() -> dict[str, Any]:
|
||||
return {"admin_enabled": admin_configured(), "version": APP_VERSION}
|
||||
return {"admin_enabled": admin_configured()}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
@@ -97,6 +102,7 @@ async def admin_login(request: Request, payload: LoginRequest) -> dict[str, Any]
|
||||
check_login_rate_limit(request)
|
||||
if payload.username != ADMIN_USERNAME or not verify_password(payload.password, os.getenv("ADMIN_PASSWORD_HASH", "")):
|
||||
record_failed_login(request)
|
||||
await asyncio.sleep(1)
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
token = create_access_token(payload.username)
|
||||
return {
|
||||
@@ -125,6 +131,95 @@ async def admin_dashboard(_: dict[str, Any] = Depends(require_admin)) -> dict[st
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
async def admin_sources(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]:
|
||||
return {
|
||||
"note": "HexaWetter kann externe APIs nutzen oder über docker-compose.selfhosted.yml eigene Instanzen anbinden.",
|
||||
"open_meteo_base_url": OPEN_METEO_BASE_URL,
|
||||
"brightsky_base_url": BRIGHTSKY_BASE_URL,
|
||||
"dwd_radar_base_url": DWD_RADAR_BASE_URL,
|
||||
"dwd_wms_url": DWD_WMS_URL,
|
||||
"dwd_attribution": "Datenbasis: Deutscher Wetterdienst (DWD), Open Data.",
|
||||
"osm_attribution": "Kartenbasis: OpenStreetMap-Mitwirkende.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/radar/sync")
|
||||
async def admin_radar_sync(
|
||||
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
|
||||
limit: int = Query(6, ge=1, le=50),
|
||||
_: dict[str, Any] = Depends(require_admin),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return await sync_radar_files(product=product, limit=limit)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Radar sync failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/radar/files")
|
||||
async def admin_radar_files(
|
||||
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
|
||||
_: dict[str, Any] = Depends(require_admin),
|
||||
) -> dict[str, Any]:
|
||||
target_dir = RAW_RADAR_DIR / "composite" / product
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
files = []
|
||||
for path in sorted(target_dir.iterdir()):
|
||||
if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name):
|
||||
continue
|
||||
modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
|
||||
files.append(
|
||||
{
|
||||
"name": path.name,
|
||||
"size_bytes": path.stat().st_size,
|
||||
"modified_utc": modified.isoformat(),
|
||||
"download_url": f"/api/admin/radar/files/{product}/{path.name}",
|
||||
}
|
||||
)
|
||||
return {"product": product, "path": str(target_dir), "count": len(files), "files": files}
|
||||
|
||||
|
||||
@router.get("/radar/files/{product}/{filename}")
|
||||
async def admin_radar_file_download(
|
||||
product: str,
|
||||
filename: str,
|
||||
_: dict[str, Any] = Depends(require_admin),
|
||||
):
|
||||
if not re.fullmatch(r"[a-z0-9_-]+", product, re.IGNORECASE):
|
||||
raise HTTPException(status_code=400, detail="Invalid product")
|
||||
if not RADAR_FILE_RE.fullmatch(filename):
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
path = RAW_RADAR_DIR / "composite" / product / filename
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
media_type = "application/x-tar" if filename.lower().endswith(".tar") else "application/octet-stream"
|
||||
return FileResponse(path, filename=filename, media_type=media_type)
|
||||
|
||||
|
||||
@router.get("/radar/rendered")
|
||||
async def admin_radar_rendered_list(
|
||||
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
|
||||
limit: int = Query(24, ge=1, le=100),
|
||||
_: dict[str, Any] = Depends(require_admin),
|
||||
) -> dict[str, Any]:
|
||||
frames = await list_rendered_radar(product, limit=limit)
|
||||
return {"product": product, "count": len(frames), "frames": frames}
|
||||
|
||||
|
||||
@router.get("/radar/rendered/{product}/{filename}.png")
|
||||
async def admin_radar_rendered_image(
|
||||
product: str,
|
||||
filename: str,
|
||||
_: dict[str, Any] = Depends(require_admin),
|
||||
):
|
||||
if not re.fullmatch(r"[a-z0-9_.-]+", filename, re.IGNORECASE):
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
path = RENDERED_RADAR_DIR / product / f"{filename}.png"
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="Rendered image not found")
|
||||
return FileResponse(path, media_type="image/png")
|
||||
|
||||
|
||||
@router.post("/cache/clear")
|
||||
async def admin_clear_cache(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]:
|
||||
from app.cache import flush_cache
|
||||
|
||||
70
api/app/security.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Iterable
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.config import ALLOWED_HOSTS, API_KEY, REQUIRE_API_KEY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_KEY_EXEMPT_PATHS: frozenset[str] = frozenset(
|
||||
{
|
||||
"/api/health",
|
||||
"/api/admin/login",
|
||||
"/api/admin/status",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def api_key_configured() -> bool:
|
||||
return bool(API_KEY)
|
||||
|
||||
|
||||
def api_key_required() -> bool:
|
||||
return REQUIRE_API_KEY or api_key_configured()
|
||||
|
||||
|
||||
def validate_security_config() -> None:
|
||||
if REQUIRE_API_KEY and not API_KEY:
|
||||
raise RuntimeError("REQUIRE_API_KEY is enabled but API_KEY is not set. Generate one with: python scripts/setup_secrets.py")
|
||||
if API_KEY and len(API_KEY) < 24:
|
||||
raise RuntimeError("API_KEY must be at least 24 characters")
|
||||
if api_key_configured() and not REQUIRE_API_KEY:
|
||||
logger.warning("API_KEY is set but REQUIRE_API_KEY=false — API key auth is still enforced when API_KEY is present")
|
||||
if not api_key_configured() and not REQUIRE_API_KEY:
|
||||
logger.warning("API is running without API_KEY — set API_KEY and REQUIRE_API_KEY=true for production")
|
||||
|
||||
|
||||
def is_api_key_exempt(path: str) -> bool:
|
||||
return path in API_KEY_EXEMPT_PATHS
|
||||
|
||||
|
||||
def extract_api_key(request: Request) -> str:
|
||||
return request.headers.get("X-API-Key", "").strip()
|
||||
|
||||
|
||||
def verify_request_api_key(request: Request) -> bool:
|
||||
if not api_key_required():
|
||||
return True
|
||||
provided = extract_api_key(request)
|
||||
if not provided:
|
||||
return False
|
||||
return secrets.compare_digest(provided, API_KEY)
|
||||
|
||||
|
||||
def cors_allowed_origins() -> list[str]:
|
||||
from app.config import ALLOWED_ORIGINS
|
||||
|
||||
if ALLOWED_ORIGINS:
|
||||
return list(ALLOWED_ORIGINS)
|
||||
if api_key_required():
|
||||
return []
|
||||
return ["*"]
|
||||
|
||||
|
||||
def trusted_hosts() -> Iterable[str]:
|
||||
return ALLOWED_HOSTS
|
||||
59
api/app/services/radar_sync.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.config import RADAR_PRODUCT, RADAR_RETENTION_HOURS, RAW_RADAR_DIR
|
||||
from app.database import upsert_radar_meta
|
||||
from app.http_client import get_client
|
||||
from app.services.dwd_radar import RADAR_FILE_RE, fetch_radar_index
|
||||
|
||||
_FILENAME_RE = re.compile(r"^[a-z0-9_-]+$", re.IGNORECASE)
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def cleanup_old_radar_files() -> None:
|
||||
cutoff = utc_now() - timedelta(hours=RADAR_RETENTION_HOURS)
|
||||
if not RAW_RADAR_DIR.exists():
|
||||
return
|
||||
for path in RAW_RADAR_DIR.rglob("*"):
|
||||
if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name):
|
||||
continue
|
||||
modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
|
||||
if modified < cutoff:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
async def sync_radar_files(product: str = RADAR_PRODUCT, limit: int = 6) -> dict:
|
||||
if not _FILENAME_RE.fullmatch(product):
|
||||
raise ValueError("Invalid product")
|
||||
|
||||
latest = await fetch_radar_index(product=product, limit=limit)
|
||||
target_dir = RAW_RADAR_DIR / "composite" / product
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
downloaded: list[str] = []
|
||||
skipped: list[str] = []
|
||||
|
||||
client = get_client()
|
||||
for item in latest["files"]:
|
||||
out = target_dir / item["name"]
|
||||
if out.exists() and out.stat().st_size > 0:
|
||||
skipped.append(item["name"])
|
||||
continue
|
||||
response = await client.get(item["url"], timeout=90.0)
|
||||
response.raise_for_status()
|
||||
out.write_bytes(response.content)
|
||||
downloaded.append(item["name"])
|
||||
modified = datetime.fromtimestamp(out.stat().st_mtime, timezone.utc)
|
||||
await upsert_radar_meta(product, item["name"], out.stat().st_size, modified, "pending")
|
||||
|
||||
cleanup_old_radar_files()
|
||||
return {
|
||||
"product": product,
|
||||
"target_dir": str(target_dir),
|
||||
"downloaded": downloaded,
|
||||
"skipped": skipped,
|
||||
}
|
||||
@@ -86,11 +86,11 @@ async def fetch_all_warnings() -> list[dict[str, Any]]:
|
||||
|
||||
client = get_client()
|
||||
response = await client.get(DWD_WARNINGS_URL, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
text = response.text
|
||||
if text.startswith("warnWetter.loadWarnings("):
|
||||
text = text[text.index("(") + 1 : text.rindex(")")]
|
||||
payload = json.loads(text)
|
||||
response.raise_for_status()
|
||||
text = response.text
|
||||
if text.startswith("warnWetter.loadWarnings("):
|
||||
text = text[text.index("(") + 1 : text.rindex(")")]
|
||||
payload = json.loads(text)
|
||||
|
||||
warnings = _flatten_warnings(payload)
|
||||
await cache_set(cache_key, warnings, CACHE_TTL_WARNINGS)
|
||||
|
||||
@@ -4,6 +4,7 @@ import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from app.cache import cache_get, cache_set
|
||||
from app.http_client import get_client
|
||||
from app.config import CACHE_TTL_WMS_TIMES, DWD_WMS_URL
|
||||
|
||||
@@ -59,10 +60,10 @@ async def fetch_wms_time_steps(layer: str, minutes: int = 120) -> dict:
|
||||
params = {"service": "WMS", "version": "1.3.0", "request": "GetCapabilities"}
|
||||
client = get_client()
|
||||
response = await client.get(DWD_WMS_URL, params=params, timeout=15.0)
|
||||
response.raise_for_status()
|
||||
dimension = _find_layer_dimension(response.text, layer)
|
||||
if not dimension:
|
||||
raise ValueError(f"No TIME dimension found for layer {layer}")
|
||||
response.raise_for_status()
|
||||
dimension = _find_layer_dimension(response.text, layer)
|
||||
if not dimension:
|
||||
raise ValueError(f"No TIME dimension found for layer {layer}")
|
||||
|
||||
if "/" in dimension:
|
||||
start, end, step_minutes = _parse_time_dimension(dimension)
|
||||
|
||||
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 72 KiB |
@@ -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"
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>Admin – HexaWetter</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
@@ -47,6 +48,7 @@
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,6 +82,20 @@
|
||||
</form>
|
||||
<p class="muted" id="settingsMsg" style="margin-top:10px"></p>
|
||||
</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>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -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) => `
|
||||
<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() {
|
||||
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 = {
|
||||
|
||||
298
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("") : `<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() {
|
||||
const loc = getLocation();
|
||||
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>
|
||||
</tr>
|
||||
`).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) => `<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>`;
|
||||
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) => `
|
||||
<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) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
<button class="tab" data-view="warnings">Warnungen</button>
|
||||
<button class="tab" data-view="hourly">Stunden</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>
|
||||
</nav>
|
||||
|
||||
@@ -92,10 +91,6 @@
|
||||
<div class="section-head"><h2>Kurzvorschau</h2><span class="muted">Nächste Stunden</span></div>
|
||||
<div class="hour-strip" id="hourStrip"></div>
|
||||
</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 class="view" data-view="radar">
|
||||
@@ -116,7 +111,6 @@
|
||||
<input id="radarSlider" type="range" min="0" max="0" value="0" />
|
||||
</div>
|
||||
<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>
|
||||
<span class="muted" id="radarFrameInfo"></span>
|
||||
</div>
|
||||
@@ -144,8 +138,21 @@
|
||||
|
||||
<section class="view" data-view="hourly">
|
||||
<section class="glass-card">
|
||||
<div class="section-head"><h2>Stundenansicht</h2><span class="muted">48 Stunden</span></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>
|
||||
<div class="section-head">
|
||||
<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>
|
||||
|
||||
@@ -156,24 +163,6 @@
|
||||
</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="glass-card">
|
||||
<h2>Einstellungen</h2>
|
||||
@@ -213,6 +202,7 @@
|
||||
</footer>
|
||||
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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)); }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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__")
|
||||
|
||||
50
scripts/setup_secrets.py
Normal 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()
|
||||