diff --git a/app.py b/app.py
index 7e56516..8e714b1 100644
--- a/app.py
+++ b/app.py
@@ -3,9 +3,12 @@ from flask import Flask, render_template, request, jsonify
from geopy.geocoders import Nominatim
from geopy.exc import GeocoderTimedOut
import math
-import traceback
import pandas as pd
import datetime as _dt
+import logging
+from collections import deque
+from threading import Lock
+from time import time
from wetterdienst.provider.dwd.mosmix import DwdMosmixRequest
from cachetools import TTLCache
@@ -14,6 +17,9 @@ from astral.sun import sun as astral_sun
import requests as _requests
app = Flask(__name__)
+app.logger.setLevel(logging.INFO)
+
+_GEOLOCATOR = Nominatim(user_agent="skywatcher-app/1.0")
# ── Zeitzone ────────────────────────────────────────────────────────────
try:
@@ -26,6 +32,32 @@ except ImportError:
# ── Cache: Forecasts 45 Min, DWD-Warnungen 15 Min ────────────────────────────
_forecast_cache = TTLCache(maxsize=64, ttl=2700)
_warn_cache = TTLCache(maxsize=32, ttl=900)
+_suggest_cache = TTLCache(maxsize=256, ttl=900)
+
+# API protection: simple in-memory rate limiting by client IP.
+_suggest_rate_lock = Lock()
+_suggest_rate_hits = {}
+_SUGGEST_RATE_WINDOW_SECONDS = 60
+_SUGGEST_RATE_MAX_REQUESTS = 30
+
+STATE_ALIASES = {
+ "baden-wurttemberg": {"baden-wurttemberg", "baden wuerttemberg", "bw"},
+ "bayern": {"bayern", "by"},
+ "berlin": {"berlin", "be"},
+ "brandenburg": {"brandenburg", "bb"},
+ "bremen": {"bremen", "hb"},
+ "hamburg": {"hamburg", "hh"},
+ "hessen": {"hessen", "he"},
+ "mecklenburg-vorpommern": {"mecklenburg-vorpommern", "mecklenburg vorpommern", "mv"},
+ "niedersachsen": {"niedersachsen", "ni"},
+ "nordrhein-westfalen": {"nordrhein-westfalen", "nordrhein westfalen", "nrw", "nw"},
+ "rheinland-pfalz": {"rheinland-pfalz", "rheinland pfalz", "rp"},
+ "saarland": {"saarland", "sl"},
+ "sachsen": {"sachsen", "sn"},
+ "sachsen-anhalt": {"sachsen-anhalt", "sachsen anhalt", "st"},
+ "schleswig-holstein": {"schleswig-holstein", "schleswig holstein", "sh"},
+ "thuringen": {"thuringen", "thueringen", "th"},
+}
# ── MOSMIX Parameter ─────────────────────────────────────────────────────────
MOSMIX_PARAMS = [
@@ -49,15 +81,78 @@ def _get_berlin():
import pytz
return pytz.timezone("Europe/Berlin")
-def geocode_location(query):
- geolocator = Nominatim(user_agent="skywatcher-app/1.0")
+def _normalize_text(value):
+ if not value:
+ return ""
+ text = str(value).strip().lower()
+ replacements = {
+ "ä": "a",
+ "ö": "o",
+ "ü": "u",
+ "ß": "ss",
+ }
+ for src, dst in replacements.items():
+ text = text.replace(src, dst)
+ return " ".join(text.split())
+
+def _state_tokens(state_name):
+ normalized = _normalize_text(state_name)
+ if not normalized:
+ return set()
+ for _, aliases in STATE_ALIASES.items():
+ if normalized in aliases:
+ return aliases
+ return {normalized}
+
+def _warning_matches_state(state_hint, warning_state, warning_state_short):
+ hint_tokens = _state_tokens(state_hint)
+ warning_tokens = _state_tokens(warning_state)
+ warning_short = _normalize_text(warning_state_short)
+ if warning_short:
+ warning_tokens.add(warning_short)
+ return bool(hint_tokens and warning_tokens and hint_tokens.intersection(warning_tokens))
+
+def _extract_state_from_location(loc):
try:
- loc = geolocator.geocode(query, language="de", timeout=10)
+ return (loc.raw or {}).get("address", {}).get("state")
+ except Exception:
+ return None
+
+def _client_ip(req):
+ forwarded = req.headers.get("X-Forwarded-For", "").strip()
+ if forwarded:
+ return forwarded.split(",")[0].strip()
+ return req.remote_addr or "unknown"
+
+def _is_suggest_rate_limited(client_ip):
+ now = time()
+ with _suggest_rate_lock:
+ hits = _suggest_rate_hits.get(client_ip)
+ if hits is None:
+ hits = deque()
+ _suggest_rate_hits[client_ip] = hits
+ cutoff = now - _SUGGEST_RATE_WINDOW_SECONDS
+ while hits and hits[0] < cutoff:
+ hits.popleft()
+ if len(hits) >= _SUGGEST_RATE_MAX_REQUESTS:
+ return True
+ hits.append(now)
+ if len(hits) == 1:
+ stale_ips = [ip for ip, values in _suggest_rate_hits.items() if not values or values[-1] < cutoff]
+ for stale_ip in stale_ips:
+ _suggest_rate_hits.pop(stale_ip, None)
+ return False
+
+def geocode_location(query):
+ try:
+ loc = _GEOLOCATOR.geocode(query, language="de", addressdetails=True, timeout=10)
if loc:
- return loc.latitude, loc.longitude, loc.address
+ return loc.latitude, loc.longitude, loc.address, _extract_state_from_location(loc)
except GeocoderTimedOut:
pass
- return None, None, None
+ except Exception:
+ app.logger.exception("Geocoding failed for query '%s'", query)
+ return None, None, None, None
def haversine(lat1, lon1, lat2, lon2):
R = 6371
@@ -103,8 +198,9 @@ def get_sun_times(lat, lon, date=None):
except Exception:
return None, None, None, None
-def get_dwd_warnings(lat, lon):
- key = (round(lat, 1), round(lon, 1))
+def get_dwd_warnings(lat, lon, state_hint=None):
+ state_key = _normalize_text(state_hint)
+ key = (round(lat, 1), round(lon, 1), state_key)
if key in _warn_cache:
return _warn_cache[key]
try:
@@ -128,12 +224,27 @@ def get_dwd_warnings(lat, lon):
"type": w.get("event", ""),
"headline": w.get("headline", ""),
"description": w.get("description", ""),
+ "state": w.get("state", ""),
+ "state_short": w.get("stateShort", ""),
})
- warnings.sort(key=lambda x: x["level"], reverse=True)
- result = warnings[:3]
+ filtered = []
+ if state_hint:
+ filtered = [w for w in warnings if _warning_matches_state(state_hint, w.get("state"), w.get("state_short"))]
+ result_source = filtered if filtered else []
+ result_source.sort(key=lambda x: x["level"], reverse=True)
+ result = [
+ {
+ "level": w["level"],
+ "type": w["type"],
+ "headline": w["headline"],
+ "description": w["description"],
+ }
+ for w in result_source[:3]
+ ]
_warn_cache[key] = result
return result
except Exception:
+ app.logger.exception("Could not load DWD warnings")
_warn_cache[key] = []
return []
@@ -225,7 +336,7 @@ def get_mosmix_forecast(lat, lon, hours=72):
_forecast_cache[cache_key] = result_data
return result_data
except Exception:
- traceback.print_exc()
+ app.logger.exception("MOSMIX forecast loading failed")
return [], {}
@app.route("/")
@@ -239,23 +350,26 @@ def wetter():
lon_param = request.args.get("lon")
# Geolocation via Browser-Koordinaten
- lat, lon, display_name = None, None, None
+ lat, lon, display_name, state_hint = None, None, None, None
if lat_param and lon_param:
try:
lat = float(lat_param)
lon = float(lon_param)
- geolocator = Nominatim(user_agent="skywatcher-app/1.0")
- loc = geolocator.reverse((lat, lon), language="de", timeout=10)
+ if not (-90 <= lat <= 90 and -180 <= lon <= 180):
+ raise ValueError("Invalid coordinate range")
+ loc = _GEOLOCATOR.reverse((lat, lon), language="de", addressdetails=True, timeout=10)
display_name = loc.address if loc else f"{lat:.2f}, {lon:.2f}"
+ state_hint = _extract_state_from_location(loc)
if not ort or ort == "Mein Standort":
ort = display_name.split(",")[0]
except Exception:
- lat, lon, display_name = None, None, None
+ app.logger.info("Invalid or unusable browser coordinates for /wetter")
+ lat, lon, display_name, state_hint = None, None, None, None
if lat is None:
if not ort:
return render_template("index.html", error="Bitte einen Ort eingeben.")
- lat, lon, display_name = geocode_location(ort)
+ lat, lon, display_name, state_hint = geocode_location(ort)
if lat is None:
return render_template("index.html", error=f'Ort "{ort}" konnte nicht gefunden werden.')
forecast, mosmix_station = get_mosmix_forecast(lat, lon, hours=72)
@@ -280,7 +394,7 @@ def wetter():
current = forecast[current_idx]
forecast = forecast[current_idx:]
sunrise, sunset, dawn, dusk = get_sun_times(lat, lon)
- warnings = get_dwd_warnings(lat, lon)
+ warnings = get_dwd_warnings(lat, lon, state_hint=state_hint)
daily = {}
for h in forecast:
dt = h["datetime"]
@@ -331,12 +445,24 @@ def wetter():
@app.route("/api/suggest")
def suggest():
q = request.args.get("q","").strip()
- if len(q) < 2: return jsonify([])
- geolocator = Nominatim(user_agent="skywatcher-app/1.0")
+ if len(q) < 2:
+ return jsonify([])
+
+ client_ip = _client_ip(request)
+ if _is_suggest_rate_limited(client_ip):
+ return jsonify({"error": "Zu viele Anfragen. Bitte kurz warten."}), 429
+
+ cache_key = _normalize_text(q)
+ if cache_key in _suggest_cache:
+ return jsonify(_suggest_cache[cache_key])
+
try:
- results = geolocator.geocode(q, exactly_one=False, limit=5, language="de", addressdetails=True, timeout=5)
- return jsonify([{"name":r.address,"lat":r.latitude,"lon":r.longitude} for r in results] if results else [])
+ results = _GEOLOCATOR.geocode(q, exactly_one=False, limit=5, language="de", addressdetails=True, timeout=5)
+ payload = [{"name": r.address, "lat": r.latitude, "lon": r.longitude} for r in results] if results else []
+ _suggest_cache[cache_key] = payload
+ return jsonify(payload)
except Exception:
+ app.logger.exception("Suggest lookup failed for query '%s'", q)
return jsonify([])
if __name__ == "__main__":
diff --git a/templates/base.html b/templates/base.html
index 6b99090..70a2435 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -24,7 +24,7 @@
{% block content %}{% endblock %}