${w.region_name || ""} · ${w.state || ""}
-${(w.description || "").replace(/\n/g, "
")}
${w.instruction}
` : ""} - Gültig: ${w.start ? formatDateTime(w.start) : "–"} – ${w.end ? formatDateTime(w.end) : "–"} -diff --git a/.env b/.env deleted file mode 100644 index a0d8ce9..0000000 --- a/.env +++ /dev/null @@ -1,54 +0,0 @@ -# HexaWetter basic settings -WEB_PORT=8080 -API_PORT=8081 -DOMAIN=wetter.example.com -TZ=Europe/Berlin - -# Database -POSTGRES_PASSWORD=hexawetter -DATABASE_URL=postgresql://hexawetter:hexawetter@postgres:5432/hexawetter -REDIS_URL=redis://redis:6379/0 - -# API sources (replace with selfhosted instances when available) -OPEN_METEO_BASE_URL=https://api.open-meteo.com/v1 -BRIGHTSKY_BASE_URL=https://api.brightsky.dev -DWD_RADAR_BASE_URL=https://opendata.dwd.de/weather/radar/composite -DWD_WMS_URL=https://maps.dwd.de/geoserver/dwd/wms -DWD_WARNINGS_URL=https://www.dwd.de/DWD/warnungen/warnapp_landkreise/json/warnings.json -NOMINATIM_BASE_URL=https://nominatim.openstreetmap.org -NOMINATIM_USER_AGENT=HexaWetter/1.0 (selfhosted weather) - -# Default location: Hauzenberg, DE -DEFAULT_LAT=48.6546 -DEFAULT_LON=13.6252 -DEFAULT_PLACE=Hauzenberg - -# Radar settings -RADAR_PRODUCT=rv -RADAR_SYNC_LIMIT=12 -RADAR_SYNC_INTERVAL_SECONDS=300 -RADAR_RENDER_INTERVAL_SECONDS=300 -RADAR_RETENTION_HOURS=48 - -# Cache TTL (seconds) -CACHE_TTL_FORECAST=600 -CACHE_TTL_OBSERVATIONS=300 -CACHE_TTL_WARNINGS=120 -CACHE_TTL_GEOCODE=86400 -CACHE_TTL_WMS_TIMES=120 - -# Admin (generate with: python scripts/hash_admin_password.py 'your-secure-password') -ADMIN_USERNAME=admin -ADMIN_PASSWORD_HASH=$$2b$$12$$BBgUdrmwTDiYGyzSRHNbh.2F70pkzBB/B3fIRN9kGlHGy1GUP.YzG -ADMIN_JWT_SECRET=mWXQRD1HANCZ-wPh5w4ZXokSwLmfgL1U-TbGveEELp9CGuwL-eyhooHLU4NzQYNV -ADMIN_JWT_EXPIRE_HOURS=8 -ADMIN_LOGIN_MAX_ATTEMPTS=5 -ADMIN_LOGIN_WINDOW_SECONDS=300 - -# Optional selfhosted profile -OPEN_METEO_PORT=8082 -BRIGHTSKY_PORT=8083 -BRIGHTSKY_DB_PASSWORD=brightsky - -# Optional Traefik -TRAEFIK_CERTRESOLVER=letsencrypt diff --git a/.env.example b/.env.example index f94d9a5..0ce94ec 100644 --- a/.env.example +++ b/.env.example @@ -36,8 +36,18 @@ CACHE_TTL_OBSERVATIONS=300 CACHE_TTL_WARNINGS=120 CACHE_TTL_GEOCODE=86400 CACHE_TTL_WMS_TIMES=120 +CACHE_TTL_HEALTH_SOURCES=90 +CACHE_TTL_DASHBOARD=60 -# Admin (generate with: python scripts/hash_admin_password.py 'your-secure-password') +# Security (generate with: python scripts/setup_secrets.py 'your-secure-password') +API_KEY= +REQUIRE_API_KEY=true +# Comma-separated origins for CORS (empty = same-origin only when API_KEY is set) +ALLOWED_ORIGINS= +# Optional host allowlist, e.g. wetter.example.com,localhost +ALLOWED_HOSTS= + +# Admin (generate with: python scripts/setup_secrets.py 'your-secure-password') # bcrypt-Hashes enthalten $ — im .env fuer docker compose jedes $ als $$ schreiben! ADMIN_USERNAME=admin ADMIN_PASSWORD_HASH= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96d623d --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Secrets & local config +.env + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ + +# Runtime data +data/ + +# IDE / OS +.idea/ +.vscode/ +*.swp +.DS_Store diff --git a/README.md b/README.md index da9f1d4..ba13a1d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ HexaWetter ist ein vollständiges Self-Hosting-Wetterportal mit Forecast, DWD-Beobachtungen, interaktiver Radar-Karte (WMS + Zeitleiste), Warnungen, Geocoding und lokalem Radar-Rendering. +## Repository + +```bash +git clone ssh://git@git.hexahost.dev:8006/smueller/HexaWetter.git +cd HexaWetter +``` + ## Features - **Dashboard**: Aktuelles Wetter, DWD-Station, 48h-Stundenansicht, 7-Tage-Forecast @@ -16,7 +23,7 @@ HexaWetter ist ein vollständiges Self-Hosting-Wetterportal mit Forecast, DWD-Be ```bash pip install bcrypt -python scripts/hash_admin_password.py 'dein-sicheres-passwort' +python scripts/setup_secrets.py 'dein-sicheres-passwort' ``` Die ausgegebenen Werte in `.env` eintragen, dann Stack neu starten. @@ -24,11 +31,14 @@ Die ausgegebenen Werte in `.env` eintragen, dann Stack neu starten. Admin-UI: `http://localhost:8080/admin.html` Sicherheit: +- **API-Key** (`API_KEY`) für alle `/api/*`-Endpunkte (Nginx setzt den Header automatisch) - bcrypt-Passwort-Hash (niemals Klartext in `.env` committen) - JWT mit `ADMIN_JWT_SECRET` (min. 32 Zeichen) - Rate-Limiting auf Login (5 Versuche / 5 Min.) - Token nur in `sessionStorage` (nicht localStorage) - Security-Headers auf allen API-Antworten +- API-Port nur auf `127.0.0.1` gebunden (kein direkter externer Zugriff) +- Schreibende Endpunkte (Radar-Sync, Cache leeren, Einstellungen) nur im Admin-Bereich ```bash @@ -36,7 +46,7 @@ cp .env.example .env docker compose up -d --build ``` -Web-UI: `http://localhost:8080` · API: `http://localhost:8081/api/health` +Web-UI: `http://localhost:8080` · API (lokal): `http://127.0.0.1:8081/api/health` ## Architektur @@ -59,7 +69,8 @@ Web-UI: `http://localhost:8080` · API: `http://localhost:8081/api/health` | `GET /api/warnings` | DWD-Warnungen (standortbezogen) | | `GET /api/geocode/search` | Ortssuche | | `GET /api/radar/wms/times` | WMS-Zeitschritte für Animation | -| `GET /api/radar/rendered` | Lokal gerenderte Radar-Frames | +| `GET /api/admin/radar/files` | Lokale Radar-Rohdaten (Admin) | +| `GET /api/admin/radar/rendered` | Gerenderte Radar-Frames (Admin) | ## Selfhosted Forecast (optional) diff --git a/api/app/auth.py b/api/app/auth.py index be59e61..e0202b9 100644 --- a/api/app/auth.py +++ b/api/app/auth.py @@ -66,6 +66,14 @@ def decode_access_token(token: str) -> dict[str, Any]: return jwt.decode(token, ADMIN_JWT_SECRET, algorithms=["HS256"]) +def _unauthorized(detail: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=detail, + headers={"WWW-Authenticate": "Bearer"}, + ) + + async def require_admin( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), @@ -73,13 +81,13 @@ async def require_admin( if not admin_configured(): raise HTTPException(status_code=503, detail="Admin access is not configured") if credentials is None or credentials.scheme.lower() != "bearer": - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token") + raise _unauthorized("Missing bearer token") try: payload = decode_access_token(credentials.credentials) except JWTError as exc: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") from exc + raise _unauthorized("Invalid or expired token") from exc if payload.get("sub") != ADMIN_USERNAME or payload.get("scope") != "admin": - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token scope") + raise _unauthorized("Invalid token scope") return payload diff --git a/api/app/config.py b/api/app/config.py index d1fced1..a748a57 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -2,7 +2,7 @@ import os from pathlib import Path APP_NAME = "HexaWetter" -APP_VERSION = "1.1.0" +APP_VERSION = "1.2.0" DATA_DIR = Path(os.getenv("DATA_DIR", "/data")) RAW_RADAR_DIR = DATA_DIR / "raw" / "radar" @@ -42,6 +42,15 @@ CACHE_TTL_DASHBOARD = int(os.getenv("CACHE_TTL_DASHBOARD", "60")) RATE_LIMIT_PER_MINUTE = int(os.getenv("RATE_LIMIT_PER_MINUTE", "120")) +API_KEY = os.getenv("API_KEY", "") +REQUIRE_API_KEY = os.getenv("REQUIRE_API_KEY", "true").lower() in ("1", "true", "yes", "on") + +_allowed_origins = os.getenv("ALLOWED_ORIGINS", "").strip() +ALLOWED_ORIGINS = tuple(o.strip() for o in _allowed_origins.split(",") if o.strip()) + +_allowed_hosts = os.getenv("ALLOWED_HOSTS", "").strip() +ALLOWED_HOSTS = tuple(h.strip() for h in _allowed_hosts.split(",") if h.strip()) + ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin") ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH", "") ADMIN_JWT_SECRET = os.getenv("ADMIN_JWT_SECRET", "") diff --git a/api/app/database.py b/api/app/database.py index 3152c64..7e1c1a6 100644 --- a/api/app/database.py +++ b/api/app/database.py @@ -200,7 +200,7 @@ async def list_rendered_radar(product: str, limit: int = 24) -> list[dict[str, A result.append( { "filename": row["filename"], - "image_url": f"/api/radar/rendered/{product}/{row['filename']}.png", + "image_url": f"/api/admin/radar/rendered/{product}/{row['filename']}.png", "bounds": bounds, "modified_at": row["modified_at"].isoformat() if row["modified_at"] else None, "rendered_at": row["updated_at"].isoformat() if row["updated_at"] else None, diff --git a/api/app/main.py b/api/app/main.py index 92e208c..9000edf 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -1,52 +1,45 @@ from __future__ import annotations import logging -import os -import re import time from collections import defaultdict from contextlib import asynccontextmanager -from datetime import datetime, timedelta, timezone -from pathlib import Path +from datetime import datetime, timezone from typing import Any import httpx from fastapi import FastAPI, HTTPException, Query, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, JSONResponse +from fastapi.middleware.trustedhost import TrustedHostMiddleware +from fastapi.responses import JSONResponse from pydantic import BaseModel, Field -from app.auth import admin_configured -from app.cache import close_redis, init_redis, redis_available +from app.cache import close_redis, init_redis from app.config import ( APP_NAME, APP_VERSION, - BRIGHTSKY_BASE_URL, DEFAULT_LAT, DEFAULT_LON, DEFAULT_PLACE, - DWD_RADAR_BASE_URL, DWD_WMS_URL, - OPEN_METEO_BASE_URL, RADAR_PRODUCT, - RADAR_RETENTION_HOURS, RATE_LIMIT_PER_MINUTE, - RENDERED_RADAR_DIR, RAW_RADAR_DIR, + RENDERED_RADAR_DIR, ) -from app.database import ( - close_db, - init_db, - list_rendered_radar, - pool_available, - upsert_radar_meta, -) -from app.http_client import close_http_client, get_client, init_http_client +from app.database import close_db, init_db +from app.http_client import close_http_client, init_http_client from app.services.dashboard import build_dashboard -from app.services.dwd_radar import RADAR_FILE_RE, fetch_radar_index +from app.services.dwd_radar import fetch_radar_index from app.services.geocoding import reverse_geocode, search_places -from app.services.health import check_sources from app.routes.admin import router as admin_router +from app.security import ( + cors_allowed_origins, + is_api_key_exempt, + trusted_hosts, + validate_security_config, + verify_request_api_key, +) from app.services.warnings import fetch_warnings_for_location from app.services.weather import get_forecast, get_observations from app.services.wms import fetch_wms_time_steps @@ -74,6 +67,7 @@ class FavoriteLocation(BaseModel): @asynccontextmanager async def lifespan(_: FastAPI): + validate_security_config() await init_http_client() await init_db() await init_redis() @@ -87,14 +81,32 @@ async def lifespan(_: FastAPI): app = FastAPI(title=APP_NAME, version=APP_VERSION, lifespan=lifespan) app.include_router(admin_router) + +_hosts = list(trusted_hosts()) +if _hosts: + app.add_middleware(TrustedHostMiddleware, allowed_hosts=_hosts) + app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], + allow_origins=cors_allowed_origins(), + allow_methods=["GET", "POST", "PUT", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "X-API-Key"], ) +@app.middleware("http") +async def api_key_middleware(request: Request, call_next): + path = request.url.path + if path.startswith("/api/") and not is_api_key_exempt(path): + if not verify_request_api_key(request): + return JSONResponse( + status_code=401, + content={"detail": "Invalid or missing API key"}, + headers={"WWW-Authenticate": "ApiKey"}, + ) + return await call_next(request) + + @app.middleware("http") async def security_headers_middleware(request: Request, call_next): response = await call_next(request) @@ -102,6 +114,9 @@ async def security_headers_middleware(request: Request, call_next): response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" response.headers["Permissions-Policy"] = "geolocation=(self)" + if path := request.url.path: + if path.startswith("/api/admin") or path.endswith("admin.html"): + response.headers["X-Robots-Tag"] = "noindex, nofollow" return response @@ -131,20 +146,6 @@ async def health() -> dict[str, Any]: "service": APP_NAME, "version": APP_VERSION, "time_utc": utc_now().isoformat(), - "admin_enabled": admin_configured(), - "postgres": pool_available(), - "redis": redis_available(), - } - - -@app.get("/api/health/sources") -async def health_sources() -> dict[str, Any]: - sources = await check_sources() - degraded = [s for s in sources if s.get("status") not in ("ok", "disabled")] - return { - "status": "degraded" if degraded else "ok", - "time_utc": utc_now().isoformat(), - "sources": sources, } @@ -162,18 +163,6 @@ async def dashboard( raise HTTPException(status_code=502, detail=f"Dashboard error: {exc}") from exc -def cleanup_old_radar_files() -> None: - cutoff = utc_now() - timedelta(hours=RADAR_RETENTION_HOURS) - if not RAW_RADAR_DIR.exists(): - return - for path in RAW_RADAR_DIR.rglob("*"): - if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name): - continue - modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc) - if modified < cutoff: - path.unlink(missing_ok=True) - - @app.get("/api/location/default") async def default_location() -> Location: return Location() @@ -247,88 +236,6 @@ async def radar_latest( raise HTTPException(status_code=502, detail=f"DWD radar index unavailable: {exc}") from exc -@app.post("/api/radar/sync") -async def radar_sync( - product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"), - limit: int = Query(6, ge=1, le=50), -) -> dict[str, Any]: - latest = await radar_latest(product=product, limit=limit) - target_dir = RAW_RADAR_DIR / "composite" / product - target_dir.mkdir(parents=True, exist_ok=True) - downloaded = [] - skipped = [] - - client = get_client() - for item in latest["files"]: - out = target_dir / item["name"] - if out.exists() and out.stat().st_size > 0: - skipped.append(item["name"]) - continue - response = await client.get(item["url"], timeout=90.0) - response.raise_for_status() - out.write_bytes(response.content) - downloaded.append(item["name"]) - modified = datetime.fromtimestamp(out.stat().st_mtime, timezone.utc) - await upsert_radar_meta(product, item["name"], out.stat().st_size, modified, "pending") - - cleanup_old_radar_files() - return {"product": product, "target_dir": str(target_dir), "downloaded": downloaded, "skipped": skipped} - - -@app.get("/api/radar/files") -async def radar_files( - product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"), -) -> dict[str, Any]: - target_dir = RAW_RADAR_DIR / "composite" / product - target_dir.mkdir(parents=True, exist_ok=True) - files = [] - for path in sorted(target_dir.iterdir()): - if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name): - continue - modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc) - files.append( - { - "name": path.name, - "size_bytes": path.stat().st_size, - "modified_utc": modified.isoformat(), - "download_url": f"/api/radar/files/{product}/{path.name}", - } - ) - return {"product": product, "path": str(target_dir), "count": len(files), "files": files} - - -@app.get("/api/radar/files/{product}/{filename}") -async def radar_file_download(product: str, filename: str): - if not re.fullmatch(r"[a-z0-9_-]+", product, re.IGNORECASE): - raise HTTPException(status_code=400, detail="Invalid product") - if not RADAR_FILE_RE.fullmatch(filename): - raise HTTPException(status_code=400, detail="Invalid filename") - path = RAW_RADAR_DIR / "composite" / product / filename - if not path.exists(): - raise HTTPException(status_code=404, detail="File not found") - media_type = "application/x-tar" if filename.lower().endswith(".tar") else "application/octet-stream" - return FileResponse(path, filename=filename, media_type=media_type) - - -@app.get("/api/radar/rendered") -async def radar_rendered_list( - product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"), - limit: int = Query(24, ge=1, le=100), -) -> dict[str, Any]: - frames = await list_rendered_radar(product, limit=limit) - return {"product": product, "count": len(frames), "frames": frames} - - -@app.get("/api/radar/rendered/{product}/{filename}.png") -async def radar_rendered_image(product: str, filename: str): - if not re.fullmatch(r"[a-z0-9_.-]+", filename, re.IGNORECASE): - raise HTTPException(status_code=400, detail="Invalid filename") - path = RENDERED_RADAR_DIR / product / f"{filename}.png" - if not path.exists(): - raise HTTPException(status_code=404, detail="Rendered image not found") - return FileResponse(path, media_type="image/png") - - @app.get("/api/radar/wms") async def radar_wms() -> dict[str, Any]: return { @@ -391,14 +298,3 @@ async def radar_wms_times( raise HTTPException(status_code=502, detail=f"WMS time dimension unavailable: {exc}") from exc -@app.get("/api/sources") -async def sources() -> dict[str, Any]: - return { - "note": "HexaWetter kann externe APIs nutzen oder über docker-compose.selfhosted.yml eigene Instanzen anbinden.", - "open_meteo_base_url": OPEN_METEO_BASE_URL, - "brightsky_base_url": BRIGHTSKY_BASE_URL, - "dwd_radar_base_url": DWD_RADAR_BASE_URL, - "dwd_wms_url": DWD_WMS_URL, - "dwd_attribution": "Datenbasis: Deutscher Wetterdienst (DWD), Open Data.", - "osm_attribution": "Kartenbasis: OpenStreetMap-Mitwirkende.", - } diff --git a/api/app/routes/admin.py b/api/app/routes/admin.py index d51ca2f..a63dc2a 100644 --- a/api/app/routes/admin.py +++ b/api/app/routes/admin.py @@ -1,11 +1,14 @@ from __future__ import annotations +import asyncio import os +import re from datetime import datetime, timezone from pathlib import Path from typing import Any -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import FileResponse from pydantic import BaseModel, Field from app.auth import ( @@ -37,8 +40,10 @@ from app.config import ( RAW_RADAR_DIR, RENDERED_RADAR_DIR, ) -from app.database import pool_available +from app.database import list_rendered_radar, pool_available +from app.services.dwd_radar import RADAR_FILE_RE from app.services.health import check_sources +from app.services.radar_sync import sync_radar_files router = APIRouter(prefix="/api/admin", tags=["admin"]) @@ -87,7 +92,7 @@ def _public_config() -> dict[str, Any]: @router.get("/status") async def admin_status_public() -> dict[str, Any]: - return {"admin_enabled": admin_configured(), "version": APP_VERSION} + return {"admin_enabled": admin_configured()} @router.post("/login") @@ -97,6 +102,7 @@ async def admin_login(request: Request, payload: LoginRequest) -> dict[str, Any] check_login_rate_limit(request) if payload.username != ADMIN_USERNAME or not verify_password(payload.password, os.getenv("ADMIN_PASSWORD_HASH", "")): record_failed_login(request) + await asyncio.sleep(1) raise HTTPException(status_code=401, detail="Invalid credentials") token = create_access_token(payload.username) return { @@ -125,6 +131,95 @@ async def admin_dashboard(_: dict[str, Any] = Depends(require_admin)) -> dict[st } +@router.get("/sources") +async def admin_sources(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]: + return { + "note": "HexaWetter kann externe APIs nutzen oder über docker-compose.selfhosted.yml eigene Instanzen anbinden.", + "open_meteo_base_url": OPEN_METEO_BASE_URL, + "brightsky_base_url": BRIGHTSKY_BASE_URL, + "dwd_radar_base_url": DWD_RADAR_BASE_URL, + "dwd_wms_url": DWD_WMS_URL, + "dwd_attribution": "Datenbasis: Deutscher Wetterdienst (DWD), Open Data.", + "osm_attribution": "Kartenbasis: OpenStreetMap-Mitwirkende.", + } + + +@router.post("/radar/sync") +async def admin_radar_sync( + product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"), + limit: int = Query(6, ge=1, le=50), + _: dict[str, Any] = Depends(require_admin), +) -> dict[str, Any]: + try: + return await sync_radar_files(product=product, limit=limit) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Radar sync failed: {exc}") from exc + + +@router.get("/radar/files") +async def admin_radar_files( + product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"), + _: dict[str, Any] = Depends(require_admin), +) -> dict[str, Any]: + target_dir = RAW_RADAR_DIR / "composite" / product + target_dir.mkdir(parents=True, exist_ok=True) + files = [] + for path in sorted(target_dir.iterdir()): + if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name): + continue + modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc) + files.append( + { + "name": path.name, + "size_bytes": path.stat().st_size, + "modified_utc": modified.isoformat(), + "download_url": f"/api/admin/radar/files/{product}/{path.name}", + } + ) + return {"product": product, "path": str(target_dir), "count": len(files), "files": files} + + +@router.get("/radar/files/{product}/{filename}") +async def admin_radar_file_download( + product: str, + filename: str, + _: dict[str, Any] = Depends(require_admin), +): + if not re.fullmatch(r"[a-z0-9_-]+", product, re.IGNORECASE): + raise HTTPException(status_code=400, detail="Invalid product") + if not RADAR_FILE_RE.fullmatch(filename): + raise HTTPException(status_code=400, detail="Invalid filename") + path = RAW_RADAR_DIR / "composite" / product / filename + if not path.exists(): + raise HTTPException(status_code=404, detail="File not found") + media_type = "application/x-tar" if filename.lower().endswith(".tar") else "application/octet-stream" + return FileResponse(path, filename=filename, media_type=media_type) + + +@router.get("/radar/rendered") +async def admin_radar_rendered_list( + product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"), + limit: int = Query(24, ge=1, le=100), + _: dict[str, Any] = Depends(require_admin), +) -> dict[str, Any]: + frames = await list_rendered_radar(product, limit=limit) + return {"product": product, "count": len(frames), "frames": frames} + + +@router.get("/radar/rendered/{product}/{filename}.png") +async def admin_radar_rendered_image( + product: str, + filename: str, + _: dict[str, Any] = Depends(require_admin), +): + if not re.fullmatch(r"[a-z0-9_.-]+", filename, re.IGNORECASE): + raise HTTPException(status_code=400, detail="Invalid filename") + path = RENDERED_RADAR_DIR / product / f"{filename}.png" + if not path.exists(): + raise HTTPException(status_code=404, detail="Rendered image not found") + return FileResponse(path, media_type="image/png") + + @router.post("/cache/clear") async def admin_clear_cache(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]: from app.cache import flush_cache diff --git a/api/app/security.py b/api/app/security.py new file mode 100644 index 0000000..997e758 --- /dev/null +++ b/api/app/security.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import logging +import secrets +from typing import Iterable + +from fastapi import Request +from fastapi.responses import JSONResponse + +from app.config import ALLOWED_HOSTS, API_KEY, REQUIRE_API_KEY + +logger = logging.getLogger(__name__) + +API_KEY_EXEMPT_PATHS: frozenset[str] = frozenset( + { + "/api/health", + "/api/admin/login", + "/api/admin/status", + } +) + + +def api_key_configured() -> bool: + return bool(API_KEY) + + +def api_key_required() -> bool: + return REQUIRE_API_KEY or api_key_configured() + + +def validate_security_config() -> None: + if REQUIRE_API_KEY and not API_KEY: + raise RuntimeError("REQUIRE_API_KEY is enabled but API_KEY is not set. Generate one with: python scripts/setup_secrets.py") + if API_KEY and len(API_KEY) < 24: + raise RuntimeError("API_KEY must be at least 24 characters") + if api_key_configured() and not REQUIRE_API_KEY: + logger.warning("API_KEY is set but REQUIRE_API_KEY=false — API key auth is still enforced when API_KEY is present") + if not api_key_configured() and not REQUIRE_API_KEY: + logger.warning("API is running without API_KEY — set API_KEY and REQUIRE_API_KEY=true for production") + + +def is_api_key_exempt(path: str) -> bool: + return path in API_KEY_EXEMPT_PATHS + + +def extract_api_key(request: Request) -> str: + return request.headers.get("X-API-Key", "").strip() + + +def verify_request_api_key(request: Request) -> bool: + if not api_key_required(): + return True + provided = extract_api_key(request) + if not provided: + return False + return secrets.compare_digest(provided, API_KEY) + + +def cors_allowed_origins() -> list[str]: + from app.config import ALLOWED_ORIGINS + + if ALLOWED_ORIGINS: + return list(ALLOWED_ORIGINS) + if api_key_required(): + return [] + return ["*"] + + +def trusted_hosts() -> Iterable[str]: + return ALLOWED_HOSTS diff --git a/api/app/services/radar_sync.py b/api/app/services/radar_sync.py new file mode 100644 index 0000000..95afeb2 --- /dev/null +++ b/api/app/services/radar_sync.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import re +from datetime import datetime, timedelta, timezone + +from app.config import RADAR_PRODUCT, RADAR_RETENTION_HOURS, RAW_RADAR_DIR +from app.database import upsert_radar_meta +from app.http_client import get_client +from app.services.dwd_radar import RADAR_FILE_RE, fetch_radar_index + +_FILENAME_RE = re.compile(r"^[a-z0-9_-]+$", re.IGNORECASE) + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def cleanup_old_radar_files() -> None: + cutoff = utc_now() - timedelta(hours=RADAR_RETENTION_HOURS) + if not RAW_RADAR_DIR.exists(): + return + for path in RAW_RADAR_DIR.rglob("*"): + if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name): + continue + modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc) + if modified < cutoff: + path.unlink(missing_ok=True) + + +async def sync_radar_files(product: str = RADAR_PRODUCT, limit: int = 6) -> dict: + if not _FILENAME_RE.fullmatch(product): + raise ValueError("Invalid product") + + latest = await fetch_radar_index(product=product, limit=limit) + target_dir = RAW_RADAR_DIR / "composite" / product + target_dir.mkdir(parents=True, exist_ok=True) + downloaded: list[str] = [] + skipped: list[str] = [] + + client = get_client() + for item in latest["files"]: + out = target_dir / item["name"] + if out.exists() and out.stat().st_size > 0: + skipped.append(item["name"]) + continue + response = await client.get(item["url"], timeout=90.0) + response.raise_for_status() + out.write_bytes(response.content) + downloaded.append(item["name"]) + modified = datetime.fromtimestamp(out.stat().st_mtime, timezone.utc) + await upsert_radar_meta(product, item["name"], out.stat().st_size, modified, "pending") + + cleanup_old_radar_files() + return { + "product": product, + "target_dir": str(target_dir), + "downloaded": downloaded, + "skipped": skipped, + } diff --git a/api/app/services/warnings.py b/api/app/services/warnings.py index 01b453e..049a036 100644 --- a/api/app/services/warnings.py +++ b/api/app/services/warnings.py @@ -86,11 +86,11 @@ async def fetch_all_warnings() -> list[dict[str, Any]]: client = get_client() response = await client.get(DWD_WARNINGS_URL, timeout=10.0) - response.raise_for_status() - text = response.text - if text.startswith("warnWetter.loadWarnings("): - text = text[text.index("(") + 1 : text.rindex(")")] - payload = json.loads(text) + response.raise_for_status() + text = response.text + if text.startswith("warnWetter.loadWarnings("): + text = text[text.index("(") + 1 : text.rindex(")")] + payload = json.loads(text) warnings = _flatten_warnings(payload) await cache_set(cache_key, warnings, CACHE_TTL_WARNINGS) diff --git a/api/app/services/wms.py b/api/app/services/wms.py index 5160b0d..71101bd 100644 --- a/api/app/services/wms.py +++ b/api/app/services/wms.py @@ -4,6 +4,7 @@ import re from datetime import datetime, timedelta, timezone from xml.etree import ElementTree as ET +from app.cache import cache_get, cache_set from app.http_client import get_client from app.config import CACHE_TTL_WMS_TIMES, DWD_WMS_URL @@ -59,10 +60,10 @@ async def fetch_wms_time_steps(layer: str, minutes: int = 120) -> dict: params = {"service": "WMS", "version": "1.3.0", "request": "GetCapabilities"} client = get_client() response = await client.get(DWD_WMS_URL, params=params, timeout=15.0) - response.raise_for_status() - dimension = _find_layer_dimension(response.text, layer) - if not dimension: - raise ValueError(f"No TIME dimension found for layer {layer}") + response.raise_for_status() + dimension = _find_layer_dimension(response.text, layer) + if not dimension: + raise ValueError(f"No TIME dimension found for layer {layer}") if "/" in dimension: start, end, step_minutes = _parse_time_dimension(dimension) diff --git a/data/processed/radar/rv/composite_rv_20260618_2025.tar.png b/data/processed/radar/rv/composite_rv_20260618_2025.tar.png deleted file mode 100644 index 4c36a33..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2025.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2030.tar.png b/data/processed/radar/rv/composite_rv_20260618_2030.tar.png deleted file mode 100644 index 49f4664..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2030.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2035.tar.png b/data/processed/radar/rv/composite_rv_20260618_2035.tar.png deleted file mode 100644 index c7261f8..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2035.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2040.tar.png b/data/processed/radar/rv/composite_rv_20260618_2040.tar.png deleted file mode 100644 index 9d0cefa..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2040.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2045.tar.png b/data/processed/radar/rv/composite_rv_20260618_2045.tar.png deleted file mode 100644 index c288ba2..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2045.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2050.tar.png b/data/processed/radar/rv/composite_rv_20260618_2050.tar.png deleted file mode 100644 index ee7c069..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2050.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2055.tar.png b/data/processed/radar/rv/composite_rv_20260618_2055.tar.png deleted file mode 100644 index 5cbdaab..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2055.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2100.tar.png b/data/processed/radar/rv/composite_rv_20260618_2100.tar.png deleted file mode 100644 index 01e9a86..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2100.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2105.tar.png b/data/processed/radar/rv/composite_rv_20260618_2105.tar.png deleted file mode 100644 index bd6c7d9..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2105.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2110.tar.png b/data/processed/radar/rv/composite_rv_20260618_2110.tar.png deleted file mode 100644 index 8b51a9f..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2110.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2115.tar.png b/data/processed/radar/rv/composite_rv_20260618_2115.tar.png deleted file mode 100644 index b025e6a..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2115.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2120.tar.png b/data/processed/radar/rv/composite_rv_20260618_2120.tar.png deleted file mode 100644 index 4113f6d..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2120.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2125.tar.png b/data/processed/radar/rv/composite_rv_20260618_2125.tar.png deleted file mode 100644 index 1946624..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2125.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_20260618_2130.tar.png b/data/processed/radar/rv/composite_rv_20260618_2130.tar.png deleted file mode 100644 index 228619c..0000000 Binary files a/data/processed/radar/rv/composite_rv_20260618_2130.tar.png and /dev/null differ diff --git a/data/processed/radar/rv/composite_rv_LATEST.tar.png b/data/processed/radar/rv/composite_rv_LATEST.tar.png deleted file mode 100644 index c288ba2..0000000 Binary files a/data/processed/radar/rv/composite_rv_LATEST.tar.png and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606181945.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606181945.tar.bz2 deleted file mode 100644 index b2ef5e5..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606181945.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606181950.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606181950.tar.bz2 deleted file mode 100644 index b01ec87..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606181950.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606181955.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606181955.tar.bz2 deleted file mode 100644 index 409adbb..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606181955.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606182000.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606182000.tar.bz2 deleted file mode 100644 index c197d98..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606182000.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606182005.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606182005.tar.bz2 deleted file mode 100644 index 4262d36..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606182005.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606182010.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606182010.tar.bz2 deleted file mode 100644 index bc3e158..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606182010.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606182015.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606182015.tar.bz2 deleted file mode 100644 index 0185a88..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606182015.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606182020.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606182020.tar.bz2 deleted file mode 100644 index 6f6fc58..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606182020.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606182025.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606182025.tar.bz2 deleted file mode 100644 index d22d00a..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606182025.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606182030.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606182030.tar.bz2 deleted file mode 100644 index 1d4f9f8..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606182030.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606182035.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606182035.tar.bz2 deleted file mode 100644 index 4dd065e..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606182035.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV2606182040.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV2606182040.tar.bz2 deleted file mode 100644 index 986a3b0..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV2606182040.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/DE1200_RV_LATEST.tar.bz2 b/data/raw/radar/composite/rv/DE1200_RV_LATEST.tar.bz2 deleted file mode 100644 index 4dd065e..0000000 Binary files a/data/raw/radar/composite/rv/DE1200_RV_LATEST.tar.bz2 and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_1955.tar b/data/raw/radar/composite/rv/composite_rv_20260618_1955.tar deleted file mode 100644 index b583922..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_1955.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2000.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2000.tar deleted file mode 100644 index 8f85db1..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2000.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2005.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2005.tar deleted file mode 100644 index 23ab770..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2005.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2010.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2010.tar deleted file mode 100644 index d73e680..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2010.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2015.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2015.tar deleted file mode 100644 index fb635b1..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2015.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2020.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2020.tar deleted file mode 100644 index faeca92..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2020.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2025.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2025.tar deleted file mode 100644 index e3564b3..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2025.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2030.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2030.tar deleted file mode 100644 index 82c3fa8..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2030.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2035.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2035.tar deleted file mode 100644 index de31dd1..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2035.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2040.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2040.tar deleted file mode 100644 index 8b95c4c..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2040.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2045.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2045.tar deleted file mode 100644 index c0ffc01..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2045.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2050.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2050.tar deleted file mode 100644 index 85d1584..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2050.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2055.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2055.tar deleted file mode 100644 index 4b37444..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2055.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2100.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2100.tar deleted file mode 100644 index bb5978a..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2100.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2105.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2105.tar deleted file mode 100644 index f5f0a71..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2105.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2110.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2110.tar deleted file mode 100644 index 3e795c5..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2110.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2115.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2115.tar deleted file mode 100644 index 26ae847..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2115.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2120.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2120.tar deleted file mode 100644 index a6404f4..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2120.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2125.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2125.tar deleted file mode 100644 index 0cf8a0a..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2125.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2130.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2130.tar deleted file mode 100644 index c73ff94..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2130.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_20260618_2135.tar b/data/raw/radar/composite/rv/composite_rv_20260618_2135.tar deleted file mode 100644 index a4027f4..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_20260618_2135.tar and /dev/null differ diff --git a/data/raw/radar/composite/rv/composite_rv_LATEST.tar b/data/raw/radar/composite/rv/composite_rv_LATEST.tar deleted file mode 100644 index c0ffc01..0000000 Binary files a/data/raw/radar/composite/rv/composite_rv_LATEST.tar and /dev/null differ diff --git a/docker-compose.yml b/docker-compose.yml index d56c81c..8e4ff37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,7 +42,7 @@ services: volumes: - ./data:/data ports: - - "${API_PORT:-8081}:8080" + - "127.0.0.1:${API_PORT:-8081}:8080" depends_on: postgres: condition: service_healthy @@ -53,11 +53,13 @@ services: image: nginx:1.27-alpine container_name: hexawetter-frontend restart: unless-stopped + environment: + API_KEY: ${API_KEY:-} depends_on: - api volumes: - ./frontend:/usr/share/nginx/html:ro - - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./nginx.conf.template:/etc/nginx/templates/default.conf.template:ro ports: - "${WEB_PORT:-8080}:80" diff --git a/frontend/admin.html b/frontend/admin.html index aab6682..00f0c1c 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -3,6 +3,7 @@
+Lokale DWD-Dateien und gerenderte Frames
| Datei | Größe | Geändert |
|---|
Noch keine gerenderten Frames.
`; + return; + } + el("renderedFrames").innerHTML = frames.map((f, i) => ` +Renderer nicht verfügbar.
`; + } +} + function showLogin() { el("loginSection").classList.remove("admin-hidden"); el("dashboardSection").classList.add("admin-hidden"); @@ -92,6 +153,7 @@ async function loadDashboard() { el("setCacheObs").value = overrides.cache_ttl_observations ?? defaults.cache_ttl.observations; el("setCacheWarn").value = overrides.cache_ttl_warnings ?? defaults.cache_ttl.warnings; el("setRadarRetention").value = overrides.radar_retention_hours ?? defaults.radar_retention_hours; + await loadRadarData(); } el("loginForm").addEventListener("submit", async (e) => { @@ -124,6 +186,23 @@ el("clearCacheBtn").addEventListener("click", async () => { await loadDashboard(); }); +el("syncRadarBtn").addEventListener("click", async () => { + const button = el("syncRadarBtn"); + button.disabled = true; + const label = button.textContent; + button.textContent = "Sync läuft…"; + try { + const result = await api("/api/admin/radar/sync?limit=6", { method: "POST" }); + alert(`Sync abgeschlossen: ${result.downloaded?.length || 0} neu, ${result.skipped?.length || 0} übersprungen`); + await loadRadarData(); + } catch (err) { + alert(err.message); + } finally { + button.disabled = false; + button.textContent = label; + } +}); + el("settingsForm").addEventListener("submit", async (e) => { e.preventDefault(); const payload = { diff --git a/frontend/app.js b/frontend/app.js index b3d39c0..562368e 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -10,12 +10,11 @@ let layerControl = null; let osmLayer = null; let radarLayer = null; let warningLayer = null; -let localRadarOverlay = null; let radarTimes = []; let radarFrameIndex = 0; let radarPlayTimer = null; let radarPlaying = false; -let renderedFrames = []; +let hourlyChart = null; let settings = loadSettings(); let searchTimer = null; @@ -44,14 +43,6 @@ function saveFavorites(items) { localStorage.setItem(FAV_KEY, JSON.stringify(items)); } -function formatBytes(bytes) { - if (!bytes) return "–"; - const units = ["B", "KB", "MB", "GB"]; - let value = bytes, unit = 0; - while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; } - return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; -} - function weatherText(code) { const map = { 0: "Klar", 1: "Überwiegend klar", 2: "Teils bewölkt", 3: "Bewölkt", @@ -74,6 +65,139 @@ function convertWind(kmh) { return `${Math.round(kmh)} km/h`; } +function tempValue(celsius) { + if (settings.unitTemp === "fahrenheit") return celsius * 9 / 5 + 32; + return celsius; +} + +function tempUnitLabel() { + return settings.unitTemp === "fahrenheit" ? "°F" : "°C"; +} + +function windUnitLabel() { + if (settings.unitWind === "ms") return "m/s"; + if (settings.unitWind === "kn") return "kn"; + return "km/h"; +} + +function windValue(kmh) { + if (settings.unitWind === "ms") return kmh / 3.6; + if (settings.unitWind === "kn") return kmh * 0.539957; + return kmh; +} + +function chartThemeColors() { + const isLight = document.documentElement.dataset.theme === "light"; + return { + text: isLight ? "#5f5380" : "#cfc9dd", + grid: isLight ? "rgba(20, 10, 60, 0.08)" : "rgba(255, 255, 255, 0.08)", + }; +} + +function destroyHourlyChart() { + if (hourlyChart) { + hourlyChart.destroy(); + hourlyChart = null; + } +} + +function renderHourlyChart(hourly) { + if (typeof Chart === "undefined" || !hourly?.time?.length) return; + const canvas = el("hourlyChart"); + if (!canvas) return; + + destroyHourlyChart(); + const { text: textColor, grid: gridColor } = chartThemeColors(); + const tempUnit = tempUnitLabel(); + const windUnit = windUnitLabel(); + const labels = hourly.time.slice(0, 48).map((time) => + new Date(time).toLocaleString("de-DE", { weekday: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" }) + ); + + hourlyChart = new Chart(canvas, { + type: "line", + data: { + labels, + datasets: [ + { + type: "bar", + label: "Regenwahrscheinlichkeit", + data: hourly.precipitation_probability.slice(0, 48).map((v) => v ?? 0), + yAxisID: "yRain", + backgroundColor: "rgba(57, 120, 255, 0.42)", + borderColor: "rgba(57, 120, 255, 0.7)", + borderWidth: 1, + borderRadius: 4, + order: 3, + }, + { + label: `Temperatur (${tempUnit})`, + data: hourly.temperature_2m.slice(0, 48).map(tempValue), + yAxisID: "yMain", + borderColor: "#ff51f9", + backgroundColor: "rgba(255, 81, 249, 0.14)", + fill: true, + tension: 0.35, + pointRadius: 0, + pointHoverRadius: 4, + borderWidth: 2.5, + order: 1, + }, + { + label: `Wind (${windUnit})`, + data: hourly.wind_speed_10m.slice(0, 48).map((v) => windValue(v ?? 0)), + yAxisID: "yMain", + borderColor: "#a348ff", + borderDash: [5, 4], + tension: 0.35, + pointRadius: 0, + borderWidth: 1.5, + fill: false, + order: 2, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index", intersect: false }, + plugins: { + legend: { + labels: { color: textColor, usePointStyle: true, boxWidth: 10 }, + }, + tooltip: { + callbacks: { + label(ctx) { + const label = ctx.dataset.label || ""; + if (label.startsWith("Regen")) return `${label}: ${ctx.parsed.y}%`; + if (label.startsWith("Temperatur")) return `${label}: ${Math.round(ctx.parsed.y)} ${tempUnit}`; + return `${label}: ${ctx.parsed.y.toFixed(1)} ${windUnit}`; + }, + }, + }, + }, + scales: { + x: { + ticks: { color: textColor, maxRotation: 0, autoSkip: true, maxTicksLimit: 14 }, + grid: { color: gridColor }, + }, + yMain: { + position: "left", + ticks: { color: textColor }, + grid: { color: gridColor }, + }, + yRain: { + position: "right", + min: 0, + max: 100, + grid: { drawOnChartArea: false }, + ticks: { color: textColor, callback: (v) => `${v}%` }, + }, + }, + }, + }); +} + async function api(path, options = {}) { const res = await fetch(path, options); if (!res.ok) { @@ -126,15 +250,6 @@ async function loadHealth() { } } -async function loadSourceStatus() { - try { - const data = await api("/api/health/sources"); - renderSourceStatus(data.sources || []); - } catch { - el("sourceStatusSummary").textContent = "Quellenstatus nicht verfügbar"; - } -} - function applyDashboard(dash) { const loc = getLocation(); if (dash.forecast?.data) { @@ -207,18 +322,6 @@ function applyWarningsData(data) { `).join("") : `${localOnly ? "Keine aktiven Warnungen für deinen Standort." : "Keine Warnungen verfügbar."}
`; } -function renderSourceStatus(sources) { - const bad = sources.filter((s) => s.status === "error").length; - el("sourceStatusSummary").textContent = bad ? `${bad} Quelle(n) mit Problemen` : "Alle Quellen erreichbar"; - el("sourceStatusGrid").innerHTML = sources.map((s) => ` -${w.region_name || ""} · ${w.state || ""}
-${(w.description || "").replace(/\n/g, "
")}
${w.instruction}
` : ""} - Gültig: ${w.start ? formatDateTime(w.start) : "–"} – ${w.end ? formatDateTime(w.end) : "–"} -${localOnly ? "Keine aktiven Warnungen für deinen Standort." : "Keine Warnungen verfügbar."}
`; + applyWarningsData(data); } catch (err) { el("warnCount").textContent = "–"; el("warnMeta").textContent = err.message; @@ -341,56 +419,6 @@ async function loadWarnings() { } } -async function loadRadarFiles() { - const latest = await api("/api/radar/latest?limit=12"); - el("radarCount").textContent = `${latest.count} online`; - el("radarMeta").textContent = `Produkt ${latest.product.toUpperCase()} · DWD OpenData`; - const local = await api("/api/radar/files"); - el("radarFiles").innerHTML = local.files.length ? local.files.slice().reverse().map((file) => ` -Noch keine gerenderten Frames. Renderer-Worker abwarten.
`; - } catch { - el("renderedCount").textContent = "–"; - el("renderedFrames").innerHTML = `Renderer nicht verfügbar.
`; - } -} - -async function syncRadar() { - const button = el("syncRadarBtn"); - button.disabled = true; - button.textContent = "Sync läuft…"; - try { - await api("/api/radar/sync?limit=6", { method: "POST" }); - await loadRadarFiles(); - } finally { - button.disabled = false; - button.textContent = "Radar jetzt syncen"; - } -} - -async function loadSources() { - try { - const data = await api("/api/sources"); - el("sourceList").innerHTML = Object.entries(data).map(([key, value]) => ` -${String(value)}${err.message}
`; - } -} - function setRadarTime(index) { if (!radarTimes.length) return; radarFrameIndex = Math.max(0, Math.min(index, radarTimes.length - 1)); @@ -399,24 +427,11 @@ function setRadarTime(index) { el("radarTimeLabel").textContent = formatDateTime(time); el("radarFrameInfo").textContent = `Frame ${radarFrameIndex + 1} / ${radarTimes.length}`; - if (el("useLocalRadar").checked && renderedFrames.length) { - showLocalRadarFrame(radarFrameIndex); - } else if (radarLayer) { + if (radarLayer) { radarLayer.setParams({ TIME: time, _cache: Date.now() }); } } -function showLocalRadarFrame(index) { - if (!map) return; - const frame = renderedFrames[Math.min(index, renderedFrames.length - 1)]; - if (!frame?.bounds) return; - const b = frame.bounds; - const bounds = [[b.south, b.west], [b.north, b.east]]; - if (localRadarOverlay) map.removeLayer(localRadarOverlay); - localRadarOverlay = L.imageOverlay(frame.image_url + `?t=${Date.now()}`, bounds, { opacity: 0.75 }); - localRadarOverlay.addTo(map); -} - async function loadRadarTimes() { const layer = radarLayer?.wmsParams?.layers || "dwd:Niederschlagsradar"; const minutes = settings.radarMinutes || 120; @@ -600,11 +615,33 @@ function activateView(name) { document.querySelectorAll(".view").forEach((view) => view.classList.toggle("active", view.dataset.view === name)); if (name === "radar") initMap().then(() => setTimeout(() => map?.invalidateSize(), 150)); if (name === "warnings") loadWarnings(); + if (name === "hourly" && hourlyChart) setTimeout(() => hourlyChart.resize(), 80); +} + +async function loadDashboard() { + const loc = getLocation(); + const localOnly = el("warningsLocalOnly")?.checked ?? true; + const dash = await api(`/api/dashboard?lat=${loc.lat}&lon=${loc.lon}&local_only=${localOnly}`); + applyDashboard(dash); + if (dash.bundle_cached) { + el("forecastSource").textContent += " · Bundle-Cache"; + } + return dash; } async function loadAll() { - await loadHealth(); - await Promise.allSettled([loadForecast(), loadObservations(), loadRadarFiles(), loadSources(), loadWarnings()]); + loadHealth(); + const loc = getLocation(); + try { + await loadDashboard(); + } catch { + await Promise.allSettled([loadForecast(), loadObservations(), loadWarnings()]); + const radar = await api("/api/radar/latest?limit=12").catch(() => null); + if (radar) { + el("radarCount").textContent = `${radar.count} online`; + el("radarMeta").textContent = `Produkt ${radar.product.toUpperCase()} · DWD OpenData`; + } + } updateMapLocation(); if (map) loadRadarTimes(); } @@ -614,20 +651,17 @@ async function init() { await loadDefaultLocation(); renderFavorites(); await loadAll(); - setInterval(loadHealth, 30000); + setInterval(loadHealth, 60000); } document.querySelectorAll(".tab").forEach((tab) => tab.addEventListener("click", () => activateView(tab.dataset.view))); el("loadBtn").addEventListener("click", loadAll); -el("syncRadarBtn").addEventListener("click", syncRadar); el("centerMapBtn").addEventListener("click", updateMapLocation); el("reloadRadarLayersBtn").addEventListener("click", reloadRadarLayers); el("favBtn").addEventListener("click", addFavorite); el("geoBtn").addEventListener("click", useGeolocation); el("warningsLocalOnly")?.addEventListener("change", loadWarnings); el("showWarningsOnMap")?.addEventListener("change", toggleWarningsOnMap); -el("useLocalRadar")?.addEventListener("change", () => setRadarTime(radarFrameIndex)); - el("radarPlayBtn").addEventListener("click", toggleRadarPlay); el("radarPrevBtn").addEventListener("click", () => setRadarTime(radarFrameIndex - 1)); el("radarNextBtn").addEventListener("click", () => setRadarTime(radarFrameIndex + 1)); @@ -647,6 +681,7 @@ el("themeToggle").addEventListener("click", () => { settings.theme = settings.theme === "dark" ? "light" : "dark"; saveSettings(); applyTheme(); + if (latestForecast?.data?.hourly) renderHourlyChart(latestForecast.data.hourly); }); ["unitTemp", "unitWind", "radarMinutes"].forEach((id) => { @@ -656,7 +691,12 @@ el("themeToggle").addEventListener("click", () => { if (id === "unitWind") settings.unitWind = e.target.value; if (id === "radarMinutes") settings.radarMinutes = Number(e.target.value); saveSettings(); - if (latestForecast) { renderDaily(latestForecast.data.daily); renderHourly(latestForecast.data.hourly); renderHourStrip(latestForecast.data.hourly); loadForecast(); } + if (latestForecast) { + renderDaily(latestForecast.data.daily); + renderHourly(latestForecast.data.hourly); + renderHourStrip(latestForecast.data.hourly); + loadForecast(); + } if (id === "radarMinutes" && map) loadRadarTimes(); }); }); diff --git a/frontend/index.html b/frontend/index.html index 174ea5c..eacbfa8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -73,7 +73,6 @@ - @@ -92,10 +91,6 @@| Zeit | Wetter | Temp. | Regen | Wolken | Wind | Böen |
|---|
| Zeit | Wetter | Temp. | Regen | Wolken | Wind | Böen |
|---|
DWD OpenData · lokaler Renderer
| Datei | Größe | Geändert | Download |
|---|