From 46caa05020d64b95c5151d8d74fd8e481986116a Mon Sep 17 00:00:00 2001 From: rene Date: Wed, 3 Jun 2026 21:14:36 +0200 Subject: [PATCH 1/7] =?UTF-8?q?OSM-Verkn=C3=BCpfung=20(Modell=20A):=20OAut?= =?UTF-8?q?h2-Fundament=20f=C3=BCr=20Nutzer-Beitr=C3=A4ge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tabelle user_osm (access_token verschlüsselt at rest via Fernet, Schlüssel aus JWT_SECRET abgeleitet oder OSM_TOKEN_KEY). - Router /api/osm-auth: authorize (signierter state mit user_id+CSRF), callback (Code-Tausch + OSM-Name holen + speichern), status, unlink. - Profil-UI (Settings): "OSM-Konto verknüpfen" / verknüpft-als / trennen, hundehalter-spezifische Motivation. - cryptography in requirements. - Basis für dog=yes-Beiträge + Gamification/Pro (folgt). Staging-Branch. ENV nötig: OSM_CLIENT_ID, OSM_CLIENT_SECRET (Redirect-URI default staging). --- backend/database.py | 12 ++ backend/main.py | 2 + backend/requirements.txt | 1 + backend/routes/osm_auth.py | 167 ++++++++++++++++++++++++++++ backend/static/js/pages/settings.js | 55 +++++++++ 5 files changed, 237 insertions(+) create mode 100644 backend/routes/osm_auth.py diff --git a/backend/database.py b/backend/database.py index 08ac7db..ef3b9fa 100644 --- a/backend/database.py +++ b/backend/database.py @@ -356,6 +356,18 @@ def init_db(): ); CREATE INDEX IF NOT EXISTS idx_osm_pois_loc ON osm_pois(type, lat, lon); + -- OSM-Account-Verknüpfung (OAuth2) je Nutzer — Basis für OSM-Beiträge + -- ("Hund war willkommen" → dog=yes) + spätere Gamification/Pro-Freischaltung. + -- access_token verschlüsselt at rest (token_enc). + CREATE TABLE IF NOT EXISTS user_osm ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + osm_uid INTEGER NOT NULL, + osm_name TEXT NOT NULL, + token_enc TEXT NOT NULL, + scopes TEXT, + linked_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + -- VERLORENE HUNDE CREATE TABLE IF NOT EXISTS lost_dogs ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/backend/main.py b/backend/main.py index e954c83..465231d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -227,6 +227,7 @@ from routes.walks import router as walks_router from routes.events import router as events_router from routes.sitting import router as sitting_router from routes.osm import router as osm_router +from routes.osm_auth import router as osm_auth_router from routes.forum import router as forum_router from routes.lost import router as lost_router from routes.knigge import router as knigge_router @@ -292,6 +293,7 @@ app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Tre app.include_router(events_router, prefix="/api/events", tags=["Events"]) app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"]) app.include_router(osm_router, prefix="/api/osm", tags=["OSM"]) +app.include_router(osm_auth_router, prefix="/api/osm-auth", tags=["OSM-Auth"]) app.include_router(weather_router, prefix="/api/weather", tags=["Wetter"]) app.include_router(social_router, prefix="/api/social", tags=["Social"]) app.include_router(forum_router, prefix="/api/forum", tags=["Forum"]) diff --git a/backend/requirements.txt b/backend/requirements.txt index 414ec32..d45b6f8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,6 +7,7 @@ pydantic[email]==2.10.6 bcrypt==4.3.0 PyJWT==2.10.1 httpx==0.28.1 +cryptography==44.0.0 openai==1.59.2 anthropic==0.49.0 pywebpush==2.0.0 diff --git a/backend/routes/osm_auth.py b/backend/routes/osm_auth.py new file mode 100644 index 0000000..0de97b8 --- /dev/null +++ b/backend/routes/osm_auth.py @@ -0,0 +1,167 @@ +""" +OSM-Account-Verknüpfung via OAuth2 (Modell A: Beiträge laufen unter dem +eigenen OSM-Account des Nutzers). Basis fürs spätere "Hund war willkommen" +(dog=yes) + Gamification/Pro-Freischaltung. + +Flow: + 1. Frontend ruft (eingeloggt) GET /api/osm-auth/authorize → bekommt die + OSM-Authorize-URL inkl. signiertem `state` (trägt die banyaro-user_id + + CSRF-Nonce, 10 Min gültig) und leitet den Browser dorthin. + 2. OSM leitet zurück auf GET /api/osm-auth/callback?code=&state= (ohne JWT — + daher die user_id aus `state`). Token-Tausch, OSM-Name holen, Token + verschlüsselt in user_osm speichern, zurück in die App leiten. + 3. GET /status zeigt Verknüpfungsstatus, POST /unlink trennt. + +ENV: OSM_CLIENT_ID, OSM_CLIENT_SECRET, OSM_REDIRECT_URI, OSM_POST_LINK_REDIRECT. +Token-Schlüssel wird aus JWT_SECRET abgeleitet (oder OSM_TOKEN_KEY überschreibt). +""" +import os +import base64 +import hashlib +import logging +from urllib.parse import urlencode +from datetime import datetime, timezone, timedelta + +import jwt +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import RedirectResponse +from cryptography.fernet import Fernet, InvalidToken + +from database import db +from auth import get_current_user, JWT_SECRET, JWT_ALGO + +logger = logging.getLogger(__name__) +router = APIRouter() + +# --- OSM-OAuth2-Endpunkte --- +OSM_AUTHORIZE = "https://www.openstreetmap.org/oauth2/authorize" +OSM_TOKEN = "https://www.openstreetmap.org/oauth2/token" +OSM_USER_API = "https://api.openstreetmap.org/api/0.6/user/details.json" +OSM_SCOPES = "read_prefs write_api" + +CLIENT_ID = os.getenv("OSM_CLIENT_ID", "") +CLIENT_SECRET = os.getenv("OSM_CLIENT_SECRET", "") +REDIRECT_URI = os.getenv("OSM_REDIRECT_URI", "https://staging.banyaro.app/api/osm-auth/callback") +POST_LINK_REDIRECT = os.getenv("OSM_POST_LINK_REDIRECT", "/?osm=verknuepft") + +_STATE_TTL_MIN = 10 + +# Fernet-Schlüssel zur Token-Verschlüsselung: dediziertes OSM_TOKEN_KEY oder +# deterministisch aus JWT_SECRET abgeleitet (kein zusätzliches Secret nötig). +def _fernet() -> Fernet: + raw = os.getenv("OSM_TOKEN_KEY") + if raw: + return Fernet(raw.encode() if isinstance(raw, str) else raw) + key = base64.urlsafe_b64encode(hashlib.sha256(JWT_SECRET.encode()).digest()) + return Fernet(key) + +def _encrypt(token: str) -> str: + return _fernet().encrypt(token.encode()).decode() + +def _decrypt(token_enc: str) -> str: + return _fernet().decrypt(token_enc.encode()).decode() + + +# ------------------------------------------------------------------ +# GET /authorize — liefert die OSM-Authorize-URL (Frontend redirectet dorthin) +# ------------------------------------------------------------------ +@router.get("/authorize") +async def authorize(user=Depends(get_current_user)): + if not CLIENT_ID: + raise HTTPException(503, "OSM-Anbindung nicht konfiguriert (OSM_CLIENT_ID fehlt).") + state = jwt.encode( + {"uid": user["id"], + "exp": datetime.now(timezone.utc) + timedelta(minutes=_STATE_TTL_MIN), + "purpose": "osm-link"}, + JWT_SECRET, algorithm=JWT_ALGO, + ) + params = { + "response_type": "code", + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "scope": OSM_SCOPES, + "state": state, + } + url = OSM_AUTHORIZE + "?" + urlencode(params) + return {"authorize_url": url} + + +# ------------------------------------------------------------------ +# GET /callback — OSM leitet hierher zurück (Browser-Redirect, kein JWT) +# ------------------------------------------------------------------ +@router.get("/callback") +async def callback(code: str = Query(...), state: str = Query(...)): + # 1) state verifizieren → banyaro-user_id (CSRF + Zuordnung) + try: + payload = jwt.decode(state, JWT_SECRET, algorithms=[JWT_ALGO]) + if payload.get("purpose") != "osm-link": + raise ValueError("falscher state-Zweck") + uid = int(payload["uid"]) + except Exception: + raise HTTPException(400, "Ungültiger oder abgelaufener Verknüpfungs-Link.") + + # 2) code → access_token tauschen + async with httpx.AsyncClient(timeout=15) as client: + tok = await client.post(OSM_TOKEN, data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": REDIRECT_URI, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + }) + if tok.status_code != 200: + logger.warning("OSM-Token-Tausch fehlgeschlagen: %s %s", tok.status_code, tok.text[:200]) + raise HTTPException(502, "OSM-Token-Tausch fehlgeschlagen.") + access_token = tok.json().get("access_token") + if not access_token: + raise HTTPException(502, "OSM lieferte kein access_token.") + + # 3) OSM-Identität holen (uid + Anzeigename) + me = await client.get(OSM_USER_API, headers={"Authorization": f"Bearer {access_token}"}) + if me.status_code != 200: + raise HTTPException(502, "OSM-Nutzerdaten konnten nicht geladen werden.") + u = me.json().get("user", {}) + osm_uid, osm_name = u.get("id"), u.get("display_name") + if not (osm_uid and osm_name): + raise HTTPException(502, "OSM-Nutzerdaten unvollständig.") + + # 4) verschlüsselt speichern (eine Verknüpfung pro banyaro-User) + with db() as conn: + conn.execute( + """INSERT INTO user_osm (user_id, osm_uid, osm_name, token_enc, scopes, linked_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(user_id) DO UPDATE SET + osm_uid=excluded.osm_uid, osm_name=excluded.osm_name, + token_enc=excluded.token_enc, scopes=excluded.scopes, + linked_at=excluded.linked_at""", + (uid, osm_uid, osm_name, _encrypt(access_token), OSM_SCOPES), + ) + logger.info("OSM verknüpft: banyaro-user %s ↔ OSM '%s' (%s)", uid, osm_name, osm_uid) + return RedirectResponse(POST_LINK_REDIRECT, status_code=302) + + +# ------------------------------------------------------------------ +# GET /status — Verknüpfungsstatus des eingeloggten Nutzers +# ------------------------------------------------------------------ +@router.get("/status") +async def status(user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT osm_name, osm_uid, linked_at FROM user_osm WHERE user_id=?", + (user["id"],) + ).fetchone() + if not row: + return {"linked": False} + return {"linked": True, "osm_name": row["osm_name"], + "osm_uid": row["osm_uid"], "linked_at": row["linked_at"]} + + +# ------------------------------------------------------------------ +# POST /unlink — Verknüpfung trennen (Token lokal löschen) +# ------------------------------------------------------------------ +@router.post("/unlink") +async def unlink(user=Depends(get_current_user)): + with db() as conn: + conn.execute("DELETE FROM user_osm WHERE user_id=?", (user["id"],)) + return {"status": "ok"} diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 1cfbe70..e87d6e0 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -672,6 +672,13 @@ window.Page_settings = (() => { +
+
OpenStreetMap – die Karte mitverbessern
+
+
Lädt…
+
+
+
+
` : ``; + // "Hund war willkommen" (dog=yes) — nur bei OSM-POIs, wo's Sinn ergibt + const DOG_TYPES = ['restaurant', 'hotel', 'shop', 'tierarzt', 'hundesalon']; + const dogBtn = (poi.source === 'osm' && DOG_TYPES.includes(layerKey)) + ? `` + : ''; + const openHours = poi.opening_hours ? `
${poi.opening_hours}
` : ''; const phone = poi.phone @@ -1120,7 +1129,7 @@ window.Page_map = (() => { ? ` Community-Pin${poi.username ? ' · ' + poi.username + '' : ''}` : ' OpenStreetMap'}
- ${actionBtn} + ${dogBtn}${actionBtn}
`, { maxWidth: 260 }).openPopup(); @@ -1130,6 +1139,20 @@ window.Page_map = (() => { if (isOwn) _deleteUserPoi(poi.user_poi_id, marker, layerKey); else _showReportDialog(poi); }); + document.getElementById('mp-dogyes')?.addEventListener('click', async () => { + const b = document.getElementById('mp-dogyes'); + b.disabled = true; + try { + const r = await API.post('/osm-contrib/dog-friendly', { + osm_id: poi.id, osm_type: 'node', poi_type: layerKey, lat: poi.lat, lon: poi.lon, + }); + UI.toast.success(r.submitted ? 'Eingetragen — danke! 🐾' : 'Erfasst — wird übertragen 🐾'); + b.innerHTML = '✓ Eingetragen'; + } catch (e) { + UI.toast.error(e?.message || 'Konnte nicht eintragen.'); + b.disabled = false; + } + }); }, 50); } diff --git a/backend/static/landing.html b/backend/static/landing.html index 43307af..1873553 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index 7bd9eb3..0e33dea 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1156'; +const VER = '1157'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From 9afbf24535d0d035ca5e95d3e0adb21fb532e6ba Mon Sep 17 00:00:00 2001 From: rene Date: Wed, 3 Jun 2026 21:49:44 +0200 Subject: [PATCH 5/7] =?UTF-8?q?OSM-Beitr=C3=A4ge:=20"Hund=20willkommen=3F"?= =?UTF-8?q?=20=F0=9F=91=8D/=F0=9F=91=8E=20(dog=3Dyes/no)=20+=20Umdrehen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dog=no zusätzlich zu dog=yes (Pächterwechsel → Ort nicht mehr hundefreundlich). - Map-Popup: ein "Hund willkommen?"-Block mit Daumen hoch/runter statt zwei Buttons. Beide rufen /dog-friendly mit welcome=true|false. - Backend generisch: tag_value yes|no; vorhandene Markierung mit anderem Wert wird umgedreht (Update statt 409); submit_dog_tag(value); Confirm/Revert prüft gegen den jeweiligen tag_value; Changeset-Kommentar wertabhängig. --- VERSION | 2 +- backend/routes/osm_contrib.py | 94 ++++++++++++++++++++-------------- backend/static/index.html | 24 ++++----- backend/static/js/app.js | 2 +- backend/static/js/pages/map.js | 41 ++++++++++----- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- 7 files changed, 100 insertions(+), 67 deletions(-) diff --git a/VERSION b/VERSION index 646782c..5a9264f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1157 \ No newline at end of file +1158 \ No newline at end of file diff --git a/backend/routes/osm_contrib.py b/backend/routes/osm_contrib.py index 0d032ec..672be37 100644 --- a/backend/routes/osm_contrib.py +++ b/backend/routes/osm_contrib.py @@ -48,6 +48,7 @@ class DogFriendlyIn(BaseModel): poi_type: Optional[str] = None lat: float lon: float + welcome: bool = True # True → dog=yes, False → dog=no (Pächterwechsel) def _verified_count(conn, uid: int) -> int: @@ -60,13 +61,13 @@ def _verified_count(conn, uid: int) -> int: # ------------------------------------------------------------------ # OSM-Changeset-Upload (write_api): Element holen → dog=yes → Changeset. # ------------------------------------------------------------------ -_CHANGESET_XML = ( - '' - '' - '' - '' - '' -) +def _changeset_xml(value: str) -> str: + note = "Hund willkommen" if value == "yes" else "Hund nicht willkommen" + return ('' + '' + f'' + '' + '') def _mark_submitted(contrib_id: int, etype: str, changeset_id): @@ -78,9 +79,9 @@ def _mark_submitted(contrib_id: int, etype: str, changeset_id): ) -async def submit_dog_yes(contrib_id: int, osm_id: int, osm_type: str, token: str) -> bool: - """Setzt dog=yes am OSM-Element des Nutzers (eigener OAuth-Token). Idempotent. - Wirft bei Fehler → Beitrag bleibt 'pending' (Retry über den Job).""" +async def submit_dog_tag(contrib_id: int, osm_id: int, osm_type: str, token: str, value: str) -> bool: + """Setzt dog= (yes|no) am OSM-Element des Nutzers (eigener OAuth-Token). + Idempotent. Wirft bei Fehler → Beitrag bleibt 'pending' (Retry über den Job).""" headers = {"Authorization": f"Bearer {token}"} order = [osm_type, "way" if osm_type == "node" else "node"] async with httpx.AsyncClient(timeout=20) as client: @@ -96,21 +97,21 @@ async def submit_dog_yes(contrib_id: int, osm_id: int, osm_type: str, token: str root = ET.fromstring(elem_xml) el = root.find(etype) existing = el.find("./tag[@k='dog']") - if existing is not None and existing.get("v") == "yes": + if existing is not None and existing.get("v") == value: _mark_submitted(contrib_id, etype, None) # schon gesetzt → fertig return True # 2) Changeset öffnen cs = await client.put(f"{OSM_API_BASE}/api/0.6/changeset/create", - headers=headers, content=_CHANGESET_XML) + headers=headers, content=_changeset_xml(value)) cs.raise_for_status() changeset_id = cs.text.strip() - # 3) dog=yes setzen + Element hochladen (Geometrie/andere Tags bleiben) + # 3) dog= setzen + Element hochladen (Geometrie/andere Tags bleiben) if existing is not None: - existing.set("v", "yes") + existing.set("v", value) else: - ET.SubElement(el, "tag", {"k": "dog", "v": "yes"}) + ET.SubElement(el, "tag", {"k": "dog", "v": value}) el.set("changeset", changeset_id) up = await client.put(f"{OSM_API_BASE}/api/0.6/{etype}/{osm_id}", headers=headers, content=ET.tostring(root, encoding="unicode")) @@ -132,12 +133,17 @@ async def mark_dog_friendly(body: DogFriendlyIn, user=Depends(get_current_user)) if not conn.execute("SELECT 1 FROM user_osm WHERE user_id=?", (uid,)).fetchone(): raise HTTPException(409, "Bitte zuerst dein OSM-Konto verknüpfen.") - # 1) Dedup — 1× pro POI - if conn.execute( - "SELECT 1 FROM osm_contributions WHERE user_id=? AND osm_id=? AND tag_key='dog'", + value = 'yes' if body.welcome else 'no' + + # 1) Vorhandene Markierung? Gleicher Wert → fertig. Anderer Wert → + # umdrehen erlaubt (Pächter wechseln → aus willkommen wird nicht mehr). + existing = conn.execute( + "SELECT id, tag_value FROM osm_contributions " + "WHERE user_id=? AND osm_id=? AND tag_key='dog'", (uid, body.osm_id) - ).fetchone(): - raise HTTPException(409, "Diesen Ort hast du schon als hundefreundlich markiert.") + ).fetchone() + if existing and existing['tag_value'] == value: + raise HTTPException(409, "Diesen Ort hast du schon so markiert.") # 2) Zeitkomponente: Tages-Rate-Limit today_n = conn.execute( @@ -184,16 +190,27 @@ async def mark_dog_friendly(body: DogFriendlyIn, user=Depends(get_current_user)) if poi and haversine_m(body.lat, body.lon, poi['lat'], poi['lon']) > POI_NEAR_M: raise HTTPException(422, "Position passt nicht zum gewählten Ort.") - # 5) verifiziert erfassen (pending; OSM-Upload gleich best-effort) - cur = conn.execute( - """INSERT INTO osm_contributions - (user_id, osm_id, osm_type, poi_type, tag_key, tag_value, lat, lon, - route_id, gps_distance_m, gps_points_near, status) - VALUES (?,?,?,?, 'dog','yes', ?,?, ?,?,?, 'pending')""", - (uid, body.osm_id, body.osm_type, body.poi_type, body.lat, body.lon, - best[0], round(best[1], 1), best[2]) - ) - contrib_id = cur.lastrowid + # 5) verifiziert erfassen oder umdrehen (pending; OSM-Upload gleich best-effort) + if existing: + conn.execute( + "UPDATE osm_contributions SET tag_value=?, osm_type=?, poi_type=?, " + "lat=?, lon=?, route_id=?, gps_distance_m=?, gps_points_near=?, " + "status='pending', changeset_id=NULL, submitted_at=NULL, " + "created_at=datetime('now') WHERE id=?", + (value, body.osm_type, body.poi_type, body.lat, body.lon, + best[0], round(best[1], 1), best[2], existing['id']) + ) + contrib_id = existing['id'] + else: + cur = conn.execute( + """INSERT INTO osm_contributions + (user_id, osm_id, osm_type, poi_type, tag_key, tag_value, lat, lon, + route_id, gps_distance_m, gps_points_near, status) + VALUES (?,?,?,?, 'dog',?, ?,?, ?,?,?, 'pending')""", + (uid, body.osm_id, body.osm_type, body.poi_type, value, body.lat, body.lon, + best[0], round(best[1], 1), best[2]) + ) + contrib_id = cur.lastrowid total = _verified_count(conn, uid) token_enc = conn.execute( "SELECT token_enc FROM user_osm WHERE user_id=?", (uid,) @@ -202,14 +219,14 @@ async def mark_dog_friendly(body: DogFriendlyIn, user=Depends(get_current_user)) # 6) OSM-Upload best-effort — Fehler → bleibt 'pending', Job versucht erneut submitted = False try: - submitted = await submit_dog_yes(contrib_id, body.osm_id, body.osm_type, _decrypt(token_enc)) + submitted = await submit_dog_tag(contrib_id, body.osm_id, body.osm_type, _decrypt(token_enc), value) except Exception as e: logger.warning("OSM-Upload später erneut (contrib %s): %s", contrib_id, e) - logger.info("dog=yes erfasst: user %s, osm %s, Tour %s (%.0fm, %d Pkt), submitted=%s", - uid, body.osm_id, best[0], best[1], best[2], submitted) + logger.info("dog=%s erfasst: user %s, osm %s, Tour %s (%.0fm, %d Pkt), submitted=%s", + value, uid, body.osm_id, best[0], best[1], best[2], submitted) return { - "status": "erfasst", "verified": True, "submitted": submitted, + "status": "erfasst", "value": value, "verified": True, "submitted": submitted, "verified_count": total, "badge": total >= BADGE_AT, "pro_progress": min(total, PRO_AT), "pro_at": PRO_AT, } @@ -264,19 +281,20 @@ async def run_confirmation_round(): # (1) Pending-Retry with db() as conn: pend = conn.execute( - "SELECT c.id, c.osm_id, c.osm_type, o.token_enc FROM osm_contributions c " + "SELECT c.id, c.osm_id, c.osm_type, c.tag_value, o.token_enc FROM osm_contributions c " "JOIN user_osm o ON o.user_id=c.user_id WHERE c.status='pending' LIMIT 50" ).fetchall() for r in pend: try: - await submit_dog_yes(r["id"], r["osm_id"], r["osm_type"] or "node", _decrypt(r["token_enc"])) + await submit_dog_tag(r["id"], r["osm_id"], r["osm_type"] or "node", + _decrypt(r["token_enc"]), r["tag_value"]) except Exception: pass # (2) Confirm/Revert with db() as conn: subs = conn.execute( - "SELECT id, user_id, osm_id, osm_type FROM osm_contributions " + "SELECT id, user_id, osm_id, osm_type, tag_value FROM osm_contributions " "WHERE status='submitted' AND submitted_at < datetime('now', ?)", (f"-{CONFIRM_AFTER_DAYS} days",) ).fetchall() @@ -290,7 +308,7 @@ async def run_confirmation_round(): if resp.status_code == 200: el = ET.fromstring(resp.text).find(etype) tag = el.find("./tag[@k='dog']") if el is not None else None - ok = tag is not None and tag.get("v") == "yes" + ok = tag is not None and tag.get("v") == r["tag_value"] new_status = "confirmed" if ok else "rejected" except Exception: continue # nächste Runde erneut diff --git a/backend/static/index.html b/backend/static/index.html index 0c4bd72..bb489c0 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -617,11 +617,11 @@ - - - - - + + + + + @@ -631,7 +631,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 7a79dd0..bd12889 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1157'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1158'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index 656060f..2d9dac0 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -1103,13 +1103,21 @@ window.Page_map = (() => { ? `` : ``; - // "Hund war willkommen" (dog=yes) — nur bei OSM-POIs, wo's Sinn ergibt + // "Hund willkommen?" — 👍/👎 (dog=yes/no) bei OSM-POIs, wo's Sinn ergibt. + // dog=no nötig, weil Pächter wechseln und ein Ort nicht mehr hundefreundlich wird. const DOG_TYPES = ['restaurant', 'hotel', 'shop', 'tierarzt', 'hundesalon']; const dogBtn = (poi.source === 'osm' && DOG_TYPES.includes(layerKey)) - ? `` + ? `
+
Hund willkommen?
+
+ + +
+
` : ''; const openHours = poi.opening_hours @@ -1139,20 +1147,27 @@ window.Page_map = (() => { if (isOwn) _deleteUserPoi(poi.user_poi_id, marker, layerKey); else _showReportDialog(poi); }); - document.getElementById('mp-dogyes')?.addEventListener('click', async () => { - const b = document.getElementById('mp-dogyes'); - b.disabled = true; + const _sendDog = async (welcome) => { + const yes = document.getElementById('mp-dogyes'); + const no = document.getElementById('mp-dogno'); + if (yes) yes.disabled = true; + if (no) no.disabled = true; try { const r = await API.post('/osm-contrib/dog-friendly', { - osm_id: poi.id, osm_type: 'node', poi_type: layerKey, lat: poi.lat, lon: poi.lon, + osm_id: poi.id, osm_type: 'node', poi_type: layerKey, + lat: poi.lat, lon: poi.lon, welcome, }); - UI.toast.success(r.submitted ? 'Eingetragen — danke! 🐾' : 'Erfasst — wird übertragen 🐾'); - b.innerHTML = '✓ Eingetragen'; + UI.toast.success((welcome ? 'Hund willkommen' : 'Hund nicht willkommen') + + (r.submitted ? ' — eingetragen 🐾' : ' — wird übertragen 🐾')); + marker.closePopup(); } catch (e) { UI.toast.error(e?.message || 'Konnte nicht eintragen.'); - b.disabled = false; + if (yes) yes.disabled = false; + if (no) no.disabled = false; } - }); + }; + document.getElementById('mp-dogyes')?.addEventListener('click', () => _sendDog(true)); + document.getElementById('mp-dogno')?.addEventListener('click', () => _sendDog(false)); }, 50); } diff --git a/backend/static/landing.html b/backend/static/landing.html index 1873553..4023d8f 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index 0e33dea..8f27062 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1157'; +const VER = '1158'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From b4cafc03636834518d9c4ae91ee907f003f0ba5f Mon Sep 17 00:00:00 2001 From: rene Date: Wed, 3 Jun 2026 22:02:42 +0200 Subject: [PATCH 6/7] =?UTF-8?q?OSM-Verkn=C3=BCpfung:=20Post-Link-Redirect?= =?UTF-8?q?=20auf=20Einstellungen=20(/#settings)=20statt=20App-Root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/osm_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/osm_auth.py b/backend/routes/osm_auth.py index bc5ae39..d81ba53 100644 --- a/backend/routes/osm_auth.py +++ b/backend/routes/osm_auth.py @@ -49,7 +49,7 @@ OSM_SCOPES = "read_prefs write_api" CLIENT_ID = os.getenv("OSM_CLIENT_ID", "") CLIENT_SECRET = os.getenv("OSM_CLIENT_SECRET", "") REDIRECT_URI = os.getenv("OSM_REDIRECT_URI", "https://staging.banyaro.app/api/osm-auth/callback") -POST_LINK_REDIRECT = os.getenv("OSM_POST_LINK_REDIRECT", "/?osm=verknuepft") +POST_LINK_REDIRECT = os.getenv("OSM_POST_LINK_REDIRECT", "/#settings") _STATE_TTL_MIN = 10 From 684ffa3b46630e546a10436e3f009f01fefd57bb Mon Sep 17 00:00:00 2001 From: rene Date: Wed, 3 Jun 2026 22:04:42 +0200 Subject: [PATCH 7/7] =?UTF-8?q?OSM-Verkn=C3=BCpfung:=20In-App-Hilfe=20?= =?UTF-8?q?=E2=80=9EKonto=20erstellen"=20(umgebungsabh=C3=A4ngig)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /osm-auth/status liefert signup_url + sandbox-Flag (Sandbox-URL auf Staging, echte OSM in Prod). - Settings-OSM-Karte: ausklappbare Hilfe "Noch kein OSM-Konto? Was ist das?" mit Erklärung, 3-Schritt-Anleitung, Sandbox-Testphasen-Hinweis und "Kostenloses OSM-Konto erstellen"-Link zur richtigen Instanz. --- VERSION | 2 +- backend/routes/osm_auth.py | 14 ++++++++++---- backend/static/index.html | 24 ++++++++++++------------ backend/static/js/app.js | 2 +- backend/static/js/pages/settings.js | 20 +++++++++++++++++++- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- 7 files changed, 45 insertions(+), 21 deletions(-) diff --git a/VERSION b/VERSION index 5a9264f..5ec4258 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1158 \ No newline at end of file +1159 \ No newline at end of file diff --git a/backend/routes/osm_auth.py b/backend/routes/osm_auth.py index d81ba53..213826f 100644 --- a/backend/routes/osm_auth.py +++ b/backend/routes/osm_auth.py @@ -157,10 +157,16 @@ async def status(user=Depends(get_current_user)): "SELECT osm_name, osm_uid, linked_at FROM user_osm WHERE user_id=?", (user["id"],) ).fetchone() - if not row: - return {"linked": False} - return {"linked": True, "osm_name": row["osm_name"], - "osm_uid": row["osm_uid"], "linked_at": row["linked_at"]} + # Registrierungs-URL umgebungsabhängig: Sandbox auf Staging, echte OSM in Prod. + base = { + "linked": bool(row), + "signup_url": OSM_OAUTH_BASE + "/user/new", + "sandbox": "dev.openstreetmap" in OSM_OAUTH_BASE, + "configured": bool(CLIENT_ID), + } + if row: + base.update(osm_name=row["osm_name"], osm_uid=row["osm_uid"], linked_at=row["linked_at"]) + return base # ------------------------------------------------------------------ diff --git a/backend/static/index.html b/backend/static/index.html index bb489c0..a934541 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -617,11 +617,11 @@ - - - - - + + + + + @@ -631,7 +631,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index bd12889..7a0fa2b 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1158'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1159'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index c7544b9..b55f1fb 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -977,7 +977,25 @@ window.Page_settings = (() => { font-size:var(--text-sm);font-weight:600;cursor:pointer"> OSM-Konto verknüpfen - `; + +
+ Noch kein OSM-Konto? Was ist das? +
+

OpenStreetMap ist die freie Weltkarte – von Menschen gemacht, gehört allen, keine Werbung, kein Datenverkauf. Mit einem kostenlosen Konto trägst du hundefreundliche Orte ein, die jeder Hundehalter sieht.

+

So geht's:

+
    +
  1. Konto erstellen (Benutzername + E-Mail – kein Klarname nötig)
  2. +
  3. Bestätigungs-E-Mail anklicken
  4. +
  5. Hier zurück → „OSM-Konto verknüpfen"
  6. +
+ ${st.sandbox ? `

⚠️ Testphase: Dies ist eine Test-Karte. Deine Einträge verändern die echte OpenStreetMap noch nicht.

` : ''} + + Kostenloses OSM-Konto erstellen + + +
+
`; el.querySelector('#settings-osm-link').addEventListener('click', async () => { try { const r = await API.get('/osm-auth/authorize'); diff --git a/backend/static/landing.html b/backend/static/landing.html index 4023d8f..ca06f56 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index 8f27062..31c38a3 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1158'; +const VER = '1159'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten