commit 3ebdc96381e8fde3bffaa982f28ef86b88f9144c Author: TheOnlyMace <0815cracky@gmail.com> Date: Thu Jun 18 23:45:08 2026 +0200 initial commit diff --git a/.cursor/settings.json b/.cursor/settings.json new file mode 100644 index 0000000..6f42aa2 --- /dev/null +++ b/.cursor/settings.json @@ -0,0 +1,7 @@ +{ + "plugins": { + "redis-development": { + "enabled": true + } + } +} diff --git a/.env b/.env new file mode 100644 index 0000000..a0d8ce9 --- /dev/null +++ b/.env @@ -0,0 +1,54 @@ +# 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 new file mode 100644 index 0000000..f94d9a5 --- /dev/null +++ b/.env.example @@ -0,0 +1,55 @@ +# 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') +# bcrypt-Hashes enthalten $ — im .env fuer docker compose jedes $ als $$ schreiben! +ADMIN_USERNAME=admin +ADMIN_PASSWORD_HASH= +ADMIN_JWT_SECRET= +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/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c65ff7d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + +jobs: + api-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install API dependencies + run: pip install -r api/requirements.txt + - name: Import API app + run: python -c "from app.main import app; print(app.title, app.version)" + working-directory: api + + frontend-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Verify frontend files + run: test -f frontend/index.html && test -f frontend/app.js && test -f frontend/style.css diff --git a/README.md b/README.md new file mode 100644 index 0000000..da9f1d4 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# HexaWetter – Selfhosted Wetterportal + +HexaWetter ist ein vollständiges Self-Hosting-Wetterportal mit Forecast, DWD-Beobachtungen, interaktiver Radar-Karte (WMS + Zeitleiste), Warnungen, Geocoding und lokalem Radar-Rendering. + +## Features + +- **Dashboard**: Aktuelles Wetter, DWD-Station, 48h-Stundenansicht, 7-Tage-Forecast +- **Radar-Karte**: Leaflet + OpenStreetMap + DWD WMS (Niederschlagsradar, Warnungen) +- **Radar-Animation**: Zeitleiste (60–120 Min., 5-Min-Schritte), Play/Pause, Vor/Zurück +- **Warnungen**: DWD-Warnungen mit Standortfilter (Landkreis-Matching) +- **Ortssuche**: PLZ/Ort via Nominatim-Proxy, GPS, Favoriten +- **Lokales Radar**: Worker lädt Rohdaten, Renderer erzeugt PNG-Overlays +- **Infrastruktur**: PostgreSQL + TimescaleDB + PostGIS, Redis-Cache, Healthchecks + +## Admin-Bereich + +```bash +pip install bcrypt +python scripts/hash_admin_password.py 'dein-sicheres-passwort' +``` + +Die ausgegebenen Werte in `.env` eintragen, dann Stack neu starten. + +Admin-UI: `http://localhost:8080/admin.html` + +Sicherheit: +- 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 + + +```bash +cp .env.example .env +docker compose up -d --build +``` + +Web-UI: `http://localhost:8080` · API: `http://localhost:8081/api/health` + +## Architektur + +| Service | Rolle | +|---------|-------| +| `frontend` | Nginx + statisches UI | +| `api` | FastAPI-Backend | +| `postgres` | TimescaleDB + PostGIS (Cache, Radar-Metadaten) | +| `redis` | Kurzzeit-Cache | +| `radar-worker` | DWD OpenData Download | +| `radar-renderer` | HDF5 → PNG aus Rohdaten | + +## API-Endpunkte (Auswahl) + +| Endpoint | Beschreibung | +|----------|--------------| +| `GET /api/health` | Status inkl. Quellen-Healthchecks | +| `GET /api/forecast` | 7-Tage-Forecast | +| `GET /api/observations` | DWD-Beobachtung (Bright Sky) | +| `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 | + +## Selfhosted Forecast (optional) + +```bash +docker compose -f docker-compose.yml -f docker-compose.selfhosted.yml --profile selfhosted up -d +``` + +Siehe `docs/NEXT_STEPS.md` für Hardware-Empfehlungen zu Open-Meteo und Bright Sky. + +## Konfiguration + +Wichtige `.env`-Variablen: + +- `OPEN_METEO_BASE_URL`, `BRIGHTSKY_BASE_URL` – Forecast/Beobachtungen +- `DWD_WMS_URL`, `DWD_RADAR_BASE_URL` – Radar-Karte und Rohdaten +- `POSTGRES_PASSWORD`, `RADAR_RETENTION_HOURS` +- `CACHE_TTL_*`, `RATE_LIMIT_PER_MINUTE` + +## Backup + +- PostgreSQL: `docker exec hexawetter-postgres pg_dump -U hexawetter hexawetter > backup.sql` +- Radar-Rohdaten: `./data/raw/radar/` +- Gerenderte Frames: `./data/processed/radar/` + +## Entwicklung + +```bash +cd api && pip install -r requirements.txt && uvicorn app.main:app --reload --port 8081 +``` + +## Lizenz / Attribution + +Datenbasis: Deutscher Wetterdienst (DWD), Open Data. Kartenbasis: OpenStreetMap. Keine amtliche Wetterberatung. diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..19b3939 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates tzdata \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +EXPOSE 8080 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/api/app/__init__.py b/api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/auth.py b/api/app/auth.py new file mode 100644 index 0000000..be59e61 --- /dev/null +++ b/api/app/auth.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import os +import secrets +import time +from datetime import datetime, timedelta, timezone +from typing import Any + +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +import bcrypt +from jose import JWTError, jwt + +ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin") +ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH", "") +ADMIN_JWT_SECRET = os.getenv("ADMIN_JWT_SECRET", "") +ADMIN_JWT_EXPIRE_HOURS = int(os.getenv("ADMIN_JWT_EXPIRE_HOURS", "8")) +ADMIN_LOGIN_MAX_ATTEMPTS = int(os.getenv("ADMIN_LOGIN_MAX_ATTEMPTS", "5")) +ADMIN_LOGIN_WINDOW_SECONDS = int(os.getenv("ADMIN_LOGIN_WINDOW_SECONDS", "300")) + +bearer_scheme = HTTPBearer(auto_error=False) + +_login_attempts: dict[str, list[float]] = {} + + +def admin_configured() -> bool: + return bool(ADMIN_PASSWORD_HASH and ADMIN_JWT_SECRET and len(ADMIN_JWT_SECRET) >= 32) + + +def verify_password(plain_password: str, password_hash: str) -> bool: + try: + return bcrypt.checkpw(plain_password.encode("utf-8"), password_hash.encode("utf-8")) + except (ValueError, TypeError): + return False + + +def hash_password(plain_password: str) -> str: + return bcrypt.hashpw(plain_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def _client_key(request: Request) -> str: + return request.client.host if request.client else "unknown" + + +def check_login_rate_limit(request: Request) -> None: + key = _client_key(request) + now = time.time() + attempts = [ts for ts in _login_attempts.get(key, []) if now - ts < ADMIN_LOGIN_WINDOW_SECONDS] + if len(attempts) >= ADMIN_LOGIN_MAX_ATTEMPTS: + raise HTTPException(status_code=429, detail="Too many login attempts. Try again later.") + _login_attempts[key] = attempts + + +def record_failed_login(request: Request) -> None: + key = _client_key(request) + _login_attempts.setdefault(key, []).append(time.time()) + + +def create_access_token(username: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(hours=ADMIN_JWT_EXPIRE_HOURS) + payload = {"sub": username, "exp": expire, "scope": "admin"} + return jwt.encode(payload, ADMIN_JWT_SECRET, algorithm="HS256") + + +def decode_access_token(token: str) -> dict[str, Any]: + return jwt.decode(token, ADMIN_JWT_SECRET, algorithms=["HS256"]) + + +async def require_admin( + request: Request, + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), +) -> dict[str, Any]: + 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") + 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 + if payload.get("sub") != ADMIN_USERNAME or payload.get("scope") != "admin": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token scope") + return payload + + +def generate_secret(length: int = 48) -> str: + return secrets.token_urlsafe(length) diff --git a/api/app/cache.py b/api/app/cache.py new file mode 100644 index 0000000..08224db --- /dev/null +++ b/api/app/cache.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import json +import logging +from typing import Any + +import redis.asyncio as redis + +from app.config import REDIS_URL + +logger = logging.getLogger(__name__) +_client: redis.Redis | None = None + + +async def init_redis() -> None: + global _client + try: + _client = redis.from_url(REDIS_URL, decode_responses=True) + await _client.ping() + logger.info("Redis connected") + except Exception as exc: + logger.warning("Redis unavailable, running without Redis cache: %s", exc) + _client = None + + +async def close_redis() -> None: + global _client + if _client is not None: + await _client.aclose() + _client = None + + +def redis_available() -> bool: + return _client is not None + + +async def cache_get(key: str) -> Any | None: + if _client is None: + return None + raw = await _client.get(key) + if raw is None: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + return raw + + +async def cache_set(key: str, value: Any, ttl_seconds: int) -> None: + if _client is None: + return + await _client.set(key, json.dumps(value), ex=ttl_seconds) + + +async def flush_cache() -> None: + if _client is not None: + await _client.flushdb() diff --git a/api/app/config.py b/api/app/config.py new file mode 100644 index 0000000..d1fced1 --- /dev/null +++ b/api/app/config.py @@ -0,0 +1,48 @@ +import os +from pathlib import Path + +APP_NAME = "HexaWetter" +APP_VERSION = "1.1.0" + +DATA_DIR = Path(os.getenv("DATA_DIR", "/data")) +RAW_RADAR_DIR = DATA_DIR / "raw" / "radar" +RENDERED_RADAR_DIR = DATA_DIR / "processed" / "radar" + +OPEN_METEO_BASE_URL = os.getenv("OPEN_METEO_BASE_URL", "https://api.open-meteo.com/v1").rstrip("/") +BRIGHTSKY_BASE_URL = os.getenv("BRIGHTSKY_BASE_URL", "https://api.brightsky.dev").rstrip("/") +DWD_RADAR_BASE_URL = os.getenv("DWD_RADAR_BASE_URL", "https://opendata.dwd.de/weather/radar/composite").rstrip("/") +DWD_WMS_URL = os.getenv("DWD_WMS_URL", "https://maps.dwd.de/geoserver/dwd/wms").rstrip("/") +DWD_WARNINGS_URL = os.getenv( + "DWD_WARNINGS_URL", + "https://www.dwd.de/DWD/warnungen/warnapp_landkreise/json/warnings.json", +).rstrip("/") +NOMINATIM_BASE_URL = os.getenv("NOMINATIM_BASE_URL", "https://nominatim.openstreetmap.org").rstrip("/") +NOMINATIM_USER_AGENT = os.getenv("NOMINATIM_USER_AGENT", "HexaWetter/1.0 (selfhosted weather)") + +DEFAULT_LAT = float(os.getenv("DEFAULT_LAT", "48.6546")) +DEFAULT_LON = float(os.getenv("DEFAULT_LON", "13.6252")) +DEFAULT_PLACE = os.getenv("DEFAULT_PLACE", "Hauzenberg") + +RADAR_PRODUCT = os.getenv("RADAR_PRODUCT", "rv") +RADAR_RETENTION_HOURS = int(os.getenv("RADAR_RETENTION_HOURS", "48")) + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql://hexawetter:hexawetter@postgres:5432/hexawetter", +) +REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") + +CACHE_TTL_FORECAST = int(os.getenv("CACHE_TTL_FORECAST", "600")) +CACHE_TTL_OBSERVATIONS = int(os.getenv("CACHE_TTL_OBSERVATIONS", "300")) +CACHE_TTL_WARNINGS = int(os.getenv("CACHE_TTL_WARNINGS", "120")) +CACHE_TTL_GEOCODE = int(os.getenv("CACHE_TTL_GEOCODE", "86400")) +CACHE_TTL_WMS_TIMES = int(os.getenv("CACHE_TTL_WMS_TIMES", "120")) +CACHE_TTL_HEALTH_SOURCES = int(os.getenv("CACHE_TTL_HEALTH_SOURCES", "90")) +CACHE_TTL_DASHBOARD = int(os.getenv("CACHE_TTL_DASHBOARD", "60")) + +RATE_LIMIT_PER_MINUTE = int(os.getenv("RATE_LIMIT_PER_MINUTE", "120")) + +ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin") +ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH", "") +ADMIN_JWT_SECRET = os.getenv("ADMIN_JWT_SECRET", "") +ADMIN_JWT_EXPIRE_HOURS = int(os.getenv("ADMIN_JWT_EXPIRE_HOURS", "8")) diff --git a/api/app/database.py b/api/app/database.py new file mode 100644 index 0000000..3152c64 --- /dev/null +++ b/api/app/database.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import json +import logging +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from typing import Any + +import asyncpg + +from app.config import DATABASE_URL + +logger = logging.getLogger(__name__) +_pool: asyncpg.Pool | None = None + + +async def init_db() -> None: + global _pool + try: + _pool = await asyncpg.create_pool(DATABASE_URL, min_size=1, max_size=8, command_timeout=30) + async with _pool.acquire() as conn: + await conn.execute("SELECT 1") + logger.info("PostgreSQL connected") + except Exception as exc: + logger.warning("PostgreSQL unavailable, running without DB cache: %s", exc) + _pool = None + + +async def close_db() -> None: + global _pool + if _pool is not None: + await _pool.close() + _pool = None + + +def pool_available() -> bool: + return _pool is not None + + +@asynccontextmanager +async def acquire(): + if _pool is None: + yield None + return + async with _pool.acquire() as conn: + yield conn + + +async def get_cached_json(table: str, key_cols: dict[str, Any], max_age_seconds: int) -> Any | None: + async with acquire() as conn: + if conn is None: + return None + if table == "forecast_cache": + row = await conn.fetchrow( + """ + SELECT data, fetched_at FROM forecast_cache + WHERE lat = $1 AND lon = $2 + AND fetched_at > NOW() - ($3 || ' seconds')::interval + """, + key_cols["lat"], + key_cols["lon"], + str(max_age_seconds), + ) + elif table == "observations_cache": + row = await conn.fetchrow( + """ + SELECT data, fetched_at FROM observations_cache + WHERE lat = $1 AND lon = $2 + AND fetched_at > NOW() - ($3 || ' seconds')::interval + """, + key_cols["lat"], + key_cols["lon"], + str(max_age_seconds), + ) + elif table == "geocode_cache": + row = await conn.fetchrow( + """ + SELECT results, fetched_at FROM geocode_cache + WHERE query_hash = $1 + AND fetched_at > NOW() - ($2 || ' seconds')::interval + """, + key_cols["query_hash"], + str(max_age_seconds), + ) + else: + return None + if not row: + return None + data = row["data"] if "data" in row else row["results"] + return json.loads(data) if isinstance(data, str) else data + + +async def set_cached_json(table: str, key_cols: dict[str, Any], payload: Any) -> None: + async with acquire() as conn: + if conn is None: + return + payload_json = json.dumps(payload) + if table == "forecast_cache": + await conn.execute( + """ + INSERT INTO forecast_cache (lat, lon, data, fetched_at) + VALUES ($1, $2, $3::jsonb, NOW()) + ON CONFLICT (lat, lon) DO UPDATE + SET data = EXCLUDED.data, fetched_at = NOW() + """, + key_cols["lat"], + key_cols["lon"], + payload_json, + ) + elif table == "observations_cache": + await conn.execute( + """ + INSERT INTO observations_cache (lat, lon, data, fetched_at) + VALUES ($1, $2, $3::jsonb, NOW()) + ON CONFLICT (lat, lon) DO UPDATE + SET data = EXCLUDED.data, fetched_at = NOW() + """, + key_cols["lat"], + key_cols["lon"], + payload_json, + ) + elif table == "geocode_cache": + await conn.execute( + """ + INSERT INTO geocode_cache (query_hash, query_text, results, fetched_at) + VALUES ($1, $2, $3::jsonb, NOW()) + ON CONFLICT (query_hash) DO UPDATE + SET results = EXCLUDED.results, fetched_at = NOW(), query_text = EXCLUDED.query_text + """, + key_cols["query_hash"], + key_cols.get("query_text", ""), + payload_json, + ) + elif table == "warnings_cache": + await conn.execute( + "INSERT INTO warnings_cache (data, fetched_at) VALUES ($1::jsonb, NOW())", + payload_json, + ) + + +async def upsert_radar_meta( + product: str, + filename: str, + file_size: int | None, + modified_at: datetime | None, + render_status: str = "pending", + render_path: str | None = None, + render_bounds: dict | None = None, + render_error: str | None = None, +) -> None: + async with acquire() as conn: + if conn is None: + return + await conn.execute( + """ + INSERT INTO radar_file_meta ( + product, filename, file_size, modified_at, render_status, + render_path, render_bounds, render_error, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, NOW()) + ON CONFLICT (filename) DO UPDATE SET + file_size = EXCLUDED.file_size, + modified_at = EXCLUDED.modified_at, + render_status = EXCLUDED.render_status, + render_path = EXCLUDED.render_path, + render_bounds = EXCLUDED.render_bounds, + render_error = EXCLUDED.render_error, + updated_at = NOW() + """, + product, + filename, + file_size, + modified_at, + render_status, + render_path, + json.dumps(render_bounds) if render_bounds else None, + render_error, + ) + + +async def list_rendered_radar(product: str, limit: int = 24) -> list[dict[str, Any]]: + async with acquire() as conn: + if conn is None: + return [] + rows = await conn.fetch( + """ + SELECT filename, render_path, render_bounds, modified_at, updated_at + FROM radar_file_meta + WHERE product = $1 AND render_status = 'done' AND render_path IS NOT NULL + ORDER BY modified_at DESC NULLS LAST + LIMIT $2 + """, + product, + limit, + ) + result = [] + for row in rows: + bounds = row["render_bounds"] + if isinstance(bounds, str): + bounds = json.loads(bounds) + result.append( + { + "filename": row["filename"], + "image_url": f"/api/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, + } + ) + return result + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) diff --git a/api/app/http_client.py b/api/app/http_client.py new file mode 100644 index 0000000..05943ed --- /dev/null +++ b/api/app/http_client.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import httpx + +_client: httpx.AsyncClient | None = None + + +async def init_http_client() -> None: + global _client + _client = httpx.AsyncClient( + timeout=httpx.Timeout(20.0, connect=5.0), + follow_redirects=True, + limits=httpx.Limits(max_connections=40, max_keepalive_connections=20), + ) + + +async def close_http_client() -> None: + global _client + if _client is not None: + await _client.aclose() + _client = None + + +def get_client() -> httpx.AsyncClient: + if _client is None: + raise RuntimeError("HTTP client not initialized") + return _client + + +async def fetch_json(url: str, params: dict | None = None, timeout: float | None = None) -> dict: + client = get_client() + response = await client.get(url, params=params, timeout=timeout) + response.raise_for_status() + return response.json() + + +async def fetch_text(url: str, timeout: float | None = None) -> str: + client = get_client() + response = await client.get(url, timeout=timeout) + response.raise_for_status() + return response.text diff --git a/api/app/main.py b/api/app/main.py new file mode 100644 index 0000000..92e208c --- /dev/null +++ b/api/app/main.py @@ -0,0 +1,404 @@ +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 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 pydantic import BaseModel, Field + +from app.auth import admin_configured +from app.cache import close_redis, init_redis, redis_available +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, +) +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.services.dashboard import build_dashboard +from app.services.dwd_radar import RADAR_FILE_RE, 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.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 + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", +) +logger = logging.getLogger(__name__) + +_rate_buckets: dict[str, list[float]] = defaultdict(list) + + +class Location(BaseModel): + lat: float = DEFAULT_LAT + lon: float = DEFAULT_LON + place: str = DEFAULT_PLACE + + +class FavoriteLocation(BaseModel): + place: str + lat: float = Field(ge=-90, le=90) + lon: float = Field(ge=-180, le=180) + + +@asynccontextmanager +async def lifespan(_: FastAPI): + await init_http_client() + await init_db() + await init_redis() + RENDERED_RADAR_DIR.mkdir(parents=True, exist_ok=True) + RAW_RADAR_DIR.mkdir(parents=True, exist_ok=True) + yield + await close_redis() + await close_db() + await close_http_client() + + +app = FastAPI(title=APP_NAME, version=APP_VERSION, lifespan=lifespan) +app.include_router(admin_router) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.middleware("http") +async def security_headers_middleware(request: Request, call_next): + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = "geolocation=(self)" + return response + + +@app.middleware("http") +async def rate_limit_middleware(request: Request, call_next): + if not request.url.path.startswith("/api/"): + return await call_next(request) + client_ip = request.client.host if request.client else "unknown" + now = time.time() + limit = 20 if request.url.path == "/api/admin/login" else RATE_LIMIT_PER_MINUTE + bucket = _rate_buckets[client_ip] + _rate_buckets[client_ip] = [ts for ts in bucket if now - ts < 60] + if len(_rate_buckets[client_ip]) >= limit: + return JSONResponse(status_code=429, content={"detail": "Rate limit exceeded"}) + _rate_buckets[client_ip].append(now) + return await call_next(request) + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +@app.get("/api/health") +async def health() -> dict[str, Any]: + return { + "status": "ok", + "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, + } + + +@app.get("/api/dashboard") +async def dashboard( + lat: float = Query(DEFAULT_LAT, ge=-90, le=90), + lon: float = Query(DEFAULT_LON, ge=-180, le=180), + local_only: bool = Query(True), +) -> dict[str, Any]: + try: + return await build_dashboard(lat, lon, local_only=local_only) + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"Dashboard unavailable: {exc}") from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Dashboard error: {exc}") from exc + + +def cleanup_old_radar_files() -> None: + cutoff = utc_now() - timedelta(hours=RADAR_RETENTION_HOURS) + if not RAW_RADAR_DIR.exists(): + return + for path in RAW_RADAR_DIR.rglob("*"): + if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name): + continue + modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc) + if modified < cutoff: + path.unlink(missing_ok=True) + + +@app.get("/api/location/default") +async def default_location() -> Location: + return Location() + + +@app.get("/api/geocode/search") +async def geocode_search(q: str = Query(..., min_length=2), limit: int = Query(8, ge=1, le=15)) -> dict[str, Any]: + try: + results = await search_places(q, limit=limit) + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"Geocoding unavailable: {exc}") from exc + return {"query": q, "count": len(results), "results": results} + + +@app.get("/api/geocode/reverse") +async def geocode_reverse( + lat: float = Query(..., ge=-90, le=90), + lon: float = Query(..., ge=-180, le=180), +) -> dict[str, Any]: + try: + result = await reverse_geocode(lat, lon) + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"Reverse geocoding unavailable: {exc}") from exc + return {"lat": lat, "lon": lon, "result": result} + + +@app.get("/api/forecast") +async def forecast( + lat: float = Query(DEFAULT_LAT, ge=-90, le=90), + lon: float = Query(DEFAULT_LON, ge=-180, le=180), +) -> dict[str, Any]: + try: + return await get_forecast(lat, lon) + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"Forecast source unavailable: {exc}") from exc + + +@app.get("/api/observations") +async def observations( + lat: float = Query(DEFAULT_LAT, ge=-90, le=90), + lon: float = Query(DEFAULT_LON, ge=-180, le=180), +) -> dict[str, Any]: + try: + return await get_observations(lat, lon) + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"Observation source unavailable: {exc}") from exc + + +@app.get("/api/warnings") +async def warnings( + lat: float = Query(DEFAULT_LAT, ge=-90, le=90), + lon: float = Query(DEFAULT_LON, ge=-180, le=180), + local_only: bool = Query(True), +) -> dict[str, Any]: + try: + return await fetch_warnings_for_location(lat, lon, local_only=local_only) + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"Warnings unavailable: {exc}") from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Warnings parse error: {exc}") from exc + + +@app.get("/api/radar/latest") +async def radar_latest( + product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"), + limit: int = Query(12, ge=1, le=100), +) -> dict[str, Any]: + try: + return await fetch_radar_index(product=product, limit=limit) + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"DWD radar index unavailable: {exc}") from exc + + +@app.post("/api/radar/sync") +async def radar_sync( + product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"), + limit: int = Query(6, ge=1, le=50), +) -> dict[str, Any]: + latest = await radar_latest(product=product, limit=limit) + target_dir = RAW_RADAR_DIR / "composite" / product + target_dir.mkdir(parents=True, exist_ok=True) + downloaded = [] + skipped = [] + + client = get_client() + for item in latest["files"]: + out = target_dir / item["name"] + if out.exists() and out.stat().st_size > 0: + skipped.append(item["name"]) + continue + response = await client.get(item["url"], timeout=90.0) + response.raise_for_status() + out.write_bytes(response.content) + downloaded.append(item["name"]) + modified = datetime.fromtimestamp(out.stat().st_mtime, timezone.utc) + await upsert_radar_meta(product, item["name"], out.stat().st_size, modified, "pending") + + cleanup_old_radar_files() + return {"product": product, "target_dir": str(target_dir), "downloaded": downloaded, "skipped": skipped} + + +@app.get("/api/radar/files") +async def radar_files( + product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"), +) -> dict[str, Any]: + target_dir = RAW_RADAR_DIR / "composite" / product + target_dir.mkdir(parents=True, exist_ok=True) + files = [] + for path in sorted(target_dir.iterdir()): + if not path.is_file() or not RADAR_FILE_RE.fullmatch(path.name): + continue + modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc) + files.append( + { + "name": path.name, + "size_bytes": path.stat().st_size, + "modified_utc": modified.isoformat(), + "download_url": f"/api/radar/files/{product}/{path.name}", + } + ) + return {"product": product, "path": str(target_dir), "count": len(files), "files": files} + + +@app.get("/api/radar/files/{product}/{filename}") +async def radar_file_download(product: str, filename: str): + if not re.fullmatch(r"[a-z0-9_-]+", product, re.IGNORECASE): + raise HTTPException(status_code=400, detail="Invalid product") + if not RADAR_FILE_RE.fullmatch(filename): + raise HTTPException(status_code=400, detail="Invalid filename") + path = RAW_RADAR_DIR / "composite" / product / filename + if not path.exists(): + raise HTTPException(status_code=404, detail="File not found") + media_type = "application/x-tar" if filename.lower().endswith(".tar") else "application/octet-stream" + return FileResponse(path, filename=filename, media_type=media_type) + + +@app.get("/api/radar/rendered") +async def radar_rendered_list( + product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"), + limit: int = Query(24, ge=1, le=100), +) -> dict[str, Any]: + frames = await list_rendered_radar(product, limit=limit) + return {"product": product, "count": len(frames), "frames": frames} + + +@app.get("/api/radar/rendered/{product}/{filename}.png") +async def radar_rendered_image(product: str, filename: str): + if not re.fullmatch(r"[a-z0-9_.-]+", filename, re.IGNORECASE): + raise HTTPException(status_code=400, detail="Invalid filename") + path = RENDERED_RADAR_DIR / product / f"{filename}.png" + if not path.exists(): + raise HTTPException(status_code=404, detail="Rendered image not found") + return FileResponse(path, media_type="image/png") + + +@app.get("/api/radar/wms") +async def radar_wms() -> dict[str, Any]: + return { + "service_url": DWD_WMS_URL, + "attribution": "Radar/Warnungen: Deutscher Wetterdienst (DWD)", + "default_layer": "dwd:Niederschlagsradar", + "layers": [ + { + "id": "niederschlagsradar", + "title": "Niederschlagsradar", + "layer": "dwd:Niederschlagsradar", + "opacity": 0.75, + "enabled": True, + "animated": True, + }, + { + "id": "warnungen_gemeinden", + "title": "DWD Warnungen (Gemeinden)", + "layer": "dwd:Warnungen_Gemeinden_vereinigt", + "opacity": 0.6, + "enabled": True, + "animated": False, + }, + { + "id": "radolan_ry", + "title": "RADOLAN RY", + "layer": "dwd:RADOLAN-RY", + "opacity": 0.72, + "enabled": False, + "animated": False, + }, + { + "id": "radar_wn", + "title": "Radar WN Reflektivität", + "layer": "dwd:Radar_wn-product_1x1km_ger", + "opacity": 0.68, + "enabled": False, + "animated": True, + }, + { + "id": "warnungen_landkreise", + "title": "DWD Warnungen Landkreise", + "layer": "dwd:Warnungen_Landkreise", + "opacity": 0.55, + "enabled": False, + "animated": False, + }, + ], + } + + +@app.get("/api/radar/wms/times") +async def radar_wms_times( + layer: str = Query("dwd:Niederschlagsradar"), + minutes: int = Query(120, ge=15, le=180), +) -> dict[str, Any]: + try: + return await fetch_wms_time_steps(layer, minutes=minutes) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"WMS time dimension unavailable: {exc}") from exc + + +@app.get("/api/sources") +async def sources() -> dict[str, Any]: + return { + "note": "HexaWetter kann externe APIs nutzen oder über docker-compose.selfhosted.yml eigene Instanzen anbinden.", + "open_meteo_base_url": OPEN_METEO_BASE_URL, + "brightsky_base_url": BRIGHTSKY_BASE_URL, + "dwd_radar_base_url": DWD_RADAR_BASE_URL, + "dwd_wms_url": DWD_WMS_URL, + "dwd_attribution": "Datenbasis: Deutscher Wetterdienst (DWD), Open Data.", + "osm_attribution": "Kartenbasis: OpenStreetMap-Mitwirkende.", + } diff --git a/api/app/routes/__init__.py b/api/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/routes/admin.py b/api/app/routes/admin.py new file mode 100644 index 0000000..d51ca2f --- /dev/null +++ b/api/app/routes/admin.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field + +from app.auth import ( + ADMIN_USERNAME, + admin_configured, + check_login_rate_limit, + create_access_token, + record_failed_login, + require_admin, + verify_password, +) +from app.cache import cache_get, cache_set, redis_available +from app.config import ( + APP_NAME, + APP_VERSION, + BRIGHTSKY_BASE_URL, + CACHE_TTL_FORECAST, + CACHE_TTL_GEOCODE, + CACHE_TTL_OBSERVATIONS, + CACHE_TTL_WARNINGS, + CACHE_TTL_WMS_TIMES, + DATA_DIR, + DWD_RADAR_BASE_URL, + DWD_WMS_URL, + OPEN_METEO_BASE_URL, + RADAR_PRODUCT, + RADAR_RETENTION_HOURS, + RATE_LIMIT_PER_MINUTE, + RAW_RADAR_DIR, + RENDERED_RADAR_DIR, +) +from app.database import pool_available +from app.services.health import check_sources + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + + +class LoginRequest(BaseModel): + username: str = Field(min_length=1, max_length=64) + password: str = Field(min_length=8, max_length=128) + + +class SettingsUpdate(BaseModel): + rate_limit_per_minute: int | None = Field(default=None, ge=10, le=1000) + cache_ttl_forecast: int | None = Field(default=None, ge=60, le=86400) + cache_ttl_observations: int | None = Field(default=None, ge=60, le=86400) + cache_ttl_warnings: int | None = Field(default=None, ge=30, le=3600) + radar_retention_hours: int | None = Field(default=None, ge=6, le=168) + + +def _dir_stats(path: Path) -> dict[str, Any]: + if not path.exists(): + return {"path": str(path), "exists": False, "files": 0, "bytes": 0} + files = [p for p in path.rglob("*") if p.is_file()] + total = sum(p.stat().st_size for p in files) + return {"path": str(path), "exists": True, "files": len(files), "bytes": total} + + +def _public_config() -> dict[str, Any]: + return { + "app_name": APP_NAME, + "version": APP_VERSION, + "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, + "radar_product": RADAR_PRODUCT, + "radar_retention_hours": RADAR_RETENTION_HOURS, + "rate_limit_per_minute": RATE_LIMIT_PER_MINUTE, + "cache_ttl": { + "forecast": CACHE_TTL_FORECAST, + "observations": CACHE_TTL_OBSERVATIONS, + "warnings": CACHE_TTL_WARNINGS, + "geocode": CACHE_TTL_GEOCODE, + "wms_times": CACHE_TTL_WMS_TIMES, + }, + } + + +@router.get("/status") +async def admin_status_public() -> dict[str, Any]: + return {"admin_enabled": admin_configured(), "version": APP_VERSION} + + +@router.post("/login") +async def admin_login(request: Request, payload: LoginRequest) -> dict[str, Any]: + if not admin_configured(): + raise HTTPException(status_code=503, detail="Admin login is not configured on this instance") + 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) + raise HTTPException(status_code=401, detail="Invalid credentials") + token = create_access_token(payload.username) + return { + "access_token": token, + "token_type": "bearer", + "expires_in_hours": int(os.getenv("ADMIN_JWT_EXPIRE_HOURS", "8")), + } + + +@router.get("/dashboard") +async def admin_dashboard(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]: + sources = await check_sources() + return { + "time_utc": datetime.now(timezone.utc).isoformat(), + "services": sources, + "infrastructure": { + "postgres": "ok" if pool_available() else "disabled", + "redis": "ok" if redis_available() else "disabled", + }, + "storage": { + "data_dir": _dir_stats(DATA_DIR), + "radar_raw": _dir_stats(RAW_RADAR_DIR), + "radar_rendered": _dir_stats(RENDERED_RADAR_DIR), + }, + "config": _public_config(), + } + + +@router.post("/cache/clear") +async def admin_clear_cache(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]: + from app.cache import flush_cache + + if not redis_available(): + raise HTTPException(status_code=503, detail="Redis cache is not available") + await flush_cache() + return {"status": "ok", "message": "Redis cache cleared"} + + +@router.get("/settings") +async def admin_get_settings(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]: + runtime = await cache_get("admin:runtime_settings") or {} + return {"defaults": _public_config(), "runtime_overrides": runtime} + + +@router.put("/settings") +async def admin_update_settings(payload: SettingsUpdate, _: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]: + from app.cache import cache_set + + current = await cache_get("admin:runtime_settings") or {} + updates = payload.model_dump(exclude_none=True) + if not updates: + raise HTTPException(status_code=400, detail="No settings provided") + current.update(updates) + await cache_set("admin:runtime_settings", current, ttl_seconds=86400 * 30) + return {"status": "ok", "runtime_overrides": current, "note": "Overrides are stored in Redis. Service restart applies full .env changes."} diff --git a/api/app/services/__init__.py b/api/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/services/dashboard.py b/api/app/services/dashboard.py new file mode 100644 index 0000000..823a285 --- /dev/null +++ b/api/app/services/dashboard.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from app.cache import cache_get, cache_set +from app.config import CACHE_TTL_DASHBOARD, CACHE_TTL_OBSERVATIONS, CACHE_TTL_WARNINGS +from app.services.dwd_radar import fetch_radar_index +from app.services.warnings import fetch_warnings_for_location +from app.services.weather import get_forecast, get_observations + +logger = logging.getLogger(__name__) + + +async def get_radar_summary() -> dict[str, Any]: + redis_key = "radar:summary" + cached = await cache_get(redis_key) + if cached: + return {**cached, "cached": True} + + index = await fetch_radar_index(limit=12) + payload = { + "source": index["source"], + "product": index["product"], + "count": index["count"], + "cached": False, + } + await cache_set(redis_key, payload, 120) + return payload + + +async def build_dashboard(lat: float, lon: float, local_only: bool = True) -> dict[str, Any]: + redis_key = f"dashboard:{round(lat, 4)}:{round(lon, 4)}:{int(local_only)}" + cached = await cache_get(redis_key) + if cached: + return {**cached, "bundle_cached": True} + + results = await asyncio.gather( + get_forecast(lat, lon), + get_observations(lat, lon), + fetch_warnings_for_location(lat, lon, local_only=local_only), + get_radar_summary(), + return_exceptions=True, + ) + + payload: dict[str, Any] = { + "lat": lat, + "lon": lon, + "bundle_cached": False, + "errors": {}, + } + + names = ("forecast", "observations", "warnings", "radar") + for name, result in zip(names, results): + if isinstance(result, Exception): + logger.warning("dashboard %s failed: %s", name, result) + payload["errors"][name] = str(result) + payload[name] = None + else: + payload[name] = result + + ttl = min(CACHE_TTL_DASHBOARD, CACHE_TTL_OBSERVATIONS, CACHE_TTL_WARNINGS) + if not payload["errors"]: + await cache_set(redis_key, payload, ttl) + + return payload diff --git a/api/app/services/dwd_radar.py b/api/app/services/dwd_radar.py new file mode 100644 index 0000000..576731a --- /dev/null +++ b/api/app/services/dwd_radar.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import re +from typing import Any +from urllib.parse import urljoin + +from app.config import DWD_RADAR_BASE_URL, RADAR_PRODUCT +from app.http_client import fetch_text + +RADAR_FILE_RE = re.compile(r"^[A-Za-z0-9_.-]+\.(?:tar|tar\.bz2|bz2|gz)$", re.IGNORECASE) + + +def parse_apache_index(html: str, suffixes: tuple[str, ...] | None = None) -> list[dict[str, Any]]: + files: list[dict[str, Any]] = [] + pattern = re.compile( + r'href="(?P[^"]+)"[^>]*>[^<]+\s*' + r'(?P\d{2}-[A-Za-z]{3}-\d{4})\s+(?P