Compare commits

...

7 commits

Author SHA1 Message Date
684ffa3b46 OSM-Verknüpfung: In-App-Hilfe „Konto erstellen" (umgebungsabhängig)
- /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.
2026-06-03 22:04:42 +02:00
b4cafc0363 OSM-Verknüpfung: Post-Link-Redirect auf Einstellungen (/#settings) statt App-Root 2026-06-03 22:02:42 +02:00
9afbf24535 OSM-Beiträge: "Hund willkommen?" 👍/👎 (dog=yes/no) + Umdrehen
- 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.
2026-06-03 21:49:44 +02:00
57849515ea OSM-Beiträge: Map-Button (dog=yes), Changeset-Upload, Confirm/Pro-Job
- Map-Popup: "Hund war willkommen"-Button (dog=yes) für Restaurant/Hotel/
  Shop/Tierarzt/Hundesalon → POST /osm-contrib/dog-friendly.
- OSM-Changeset-Upload (write_api): Element holen (node/way) → dog=yes →
  Changeset create/upload/close; idempotent; best-effort beim Tap.
- OSM-Endpunkte konfigurierbar (OSM_OAUTH_BASE/OSM_API_BASE) — Staging gegen
  Dev-Sandbox, KEINE echten Edits auf Produktiv-OSM.
- Scheduler-Job (täglich 03:40): Pending-Retry + Revert-Überleben (7 Tage) →
  confirmed/rejected; Pro-Freischaltung (100 confirmed = 1 Jahr, idempotent via
  osm_pro_grants). HINWEIS: is_premium/subscription direkt gesetzt — vor Prod
  mit Billing abgleichen.
- Native Attestierung/Sensoren: bewusst NICHT (iOS-App-Thema, nicht PWA).
2026-06-03 21:40:50 +02:00
dc9c0d2cc0 Build 1156: SW-Cache-Bust für OSM-Verknüpfung/dog=yes-UI (Frontend-Änderung) 2026-06-03 21:24:11 +02:00
1cfaa0264f OSM-Beiträge: dog=yes-Erfassung mit GPS/Zeit-Anti-Fraud + Gamification-Zähler
- Tabelle osm_contributions (status pending→submitted→confirmed/rejected).
- Router /api/osm-contrib: POST /dog-friendly (Anti-Fraud: GPS-Beleg über
  kürzliche eigene Tour ≤50m + Verweil-Proxy, Tour-Recency 48h, Tages-Cap,
  Dedup, Positions-Sanity), GET /status (Zähler).
- Settings-UI: Zähler "X Orte eingetragen · noch Y bis Badge/Pro".
- OSM-Changeset-Upload + Pro-Freischaltung + Geräte-Attestierung folgen separat.
2026-06-03 21:20:32 +02:00
46caa05020 OSM-Verknüpfung (Modell A): OAuth2-Fundament für Nutzer-Beiträge
- 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).
2026-06-03 21:14:36 +02:00
13 changed files with 710 additions and 17 deletions

View file

@ -1 +1 @@
1155
1159

View file

@ -356,6 +356,50 @@ 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'))
);
-- OSM-Beiträge ("Hund war willkommen" dog=yes). Anti-Fraud: GPS-Beleg
-- über eine kürzliche eigene Tour (route_id) + Zeit/Rate-Limits.
-- status: pending submitted (an OSM) confirmed (Revert-überlebt) | rejected.
CREATE TABLE IF NOT EXISTS osm_contributions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
osm_id INTEGER NOT NULL,
osm_type TEXT NOT NULL DEFAULT 'node', -- node | way
poi_type TEXT,
tag_key TEXT NOT NULL DEFAULT 'dog',
tag_value TEXT NOT NULL DEFAULT 'yes',
lat REAL,
lon REAL,
route_id INTEGER REFERENCES routes(id) ON DELETE SET NULL,
gps_distance_m REAL,
gps_points_near INTEGER,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
submitted_at TEXT,
changeset_id INTEGER,
UNIQUE(user_id, osm_id, tag_key)
);
CREATE INDEX IF NOT EXISTS idx_osm_contrib_user ON osm_contributions(user_id, status);
-- Pro-Freischaltungen aus OSM-Beiträgen (1 Zeile = 1 freigeschaltetes Jahr).
-- Idempotenz: earned = confirmed//100; nur (earned - vorhandene Zeilen) neu gewähren.
CREATE TABLE IF NOT EXISTS osm_pro_grants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- VERLORENE HUNDE
CREATE TABLE IF NOT EXISTS lost_dogs (
id INTEGER PRIMARY KEY AUTOINCREMENT,

View file

@ -227,6 +227,8 @@ 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.osm_contrib import router as osm_contrib_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 +294,8 @@ 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(osm_contrib_router, prefix="/api/osm-contrib", tags=["OSM-Beiträge"])
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"])

View file

@ -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

179
backend/routes/osm_auth.py Normal file
View file

@ -0,0 +1,179 @@
"""
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 ---
# Konfigurierbar, damit Staging gegen die Dev-Sandbox laufen kann (KEINE echten
# Edits auf der Produktiv-OSM!). Staging-.env:
# OSM_OAUTH_BASE=https://master.apis.dev.openstreetmap.org
# OSM_API_BASE=https://master.apis.dev.openstreetmap.org
OSM_OAUTH_BASE = os.getenv("OSM_OAUTH_BASE", "https://www.openstreetmap.org").rstrip("/")
OSM_API_BASE = os.getenv("OSM_API_BASE", "https://api.openstreetmap.org").rstrip("/")
OSM_AUTHORIZE = OSM_OAUTH_BASE + "/oauth2/authorize"
OSM_TOKEN = OSM_OAUTH_BASE + "/oauth2/token"
OSM_USER_API = OSM_API_BASE + "/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", "/#settings")
_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()
# 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
# ------------------------------------------------------------------
# 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"}

View file

@ -0,0 +1,324 @@
"""
OSM-Beiträge: "Hund war willkommen" (dog=yes) erfassen mit Anti-Fraud und
Gamification-Zähler.
Anti-Fraud (Defense in Depth, soweit serverseitig möglich):
- GPS-Beleg: eine kürzliche EIGENE Tour (routes.gps_track) muss am POI
vorbeiführen ( GPS_RADIUS_M) mit Verweil-Proxy ( DWELL_MIN_POINTS Punkte
im Radius ohne Pro-Punkt-Zeitstempel der beste verfügbare Dwell-Proxy).
- Zeitkomponente: Tour-Recency (ROUTE_RECENCY_H) + Tages-Rate-Limit (DAILY_CAP).
- Dedup: 1× pro POI pro User. Positions-Sanity gegen die osm_pois-Koordinate.
NOCH NICHT hier (folgt separat, höheres Risiko): Geräte-Attestierung +
Sensor-Korroboration (nativ), tatsächliches OSM-Changeset-Upload, Revert-
Überleben/Konsens, und die echte Pro-Freischaltung. Beiträge werden daher als
status='pending' verifiziert erfasst; der Zähler ist provisorisch.
"""
import json
import logging
import xml.etree.ElementTree as ET
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import Optional
import httpx
from database import db
from auth import get_current_user
from math_utils import haversine_m
from routes.osm_auth import OSM_API_BASE, _decrypt
logger = logging.getLogger(__name__)
router = APIRouter()
# --- Anti-Fraud-Parameter ---
GPS_RADIUS_M = 50 # max. Abstand POI ↔ nächster Track-Punkt
DWELL_MIN_POINTS = 2 # mind. so viele Track-Punkte im Radius (Verweil-Proxy)
ROUTE_RECENCY_H = 48 # Tour darf max. so alt sein
POI_NEAR_M = 80 # eingereichte Position muss so nah am POI sein
DAILY_CAP = 20 # max. Beiträge pro Tag/User
# --- Gamification-Schwellen ---
BADGE_AT = 10 # "Kartograf"-Badge
PRO_AT = 100 # 100 geprüfte → 1 Jahr Pro (Freischaltung folgt separat)
class DogFriendlyIn(BaseModel):
osm_id: int
osm_type: str = Field('node', pattern='^(node|way)$')
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:
return conn.execute(
"SELECT COUNT(*) FROM osm_contributions WHERE user_id=? AND status!='rejected'",
(uid,)
).fetchone()[0]
# ------------------------------------------------------------------
# OSM-Changeset-Upload (write_api): Element holen → dog=yes → Changeset.
# ------------------------------------------------------------------
def _changeset_xml(value: str) -> str:
note = "Hund willkommen" if value == "yes" else "Hund nicht willkommen"
return ('<osm><changeset>'
'<tag k="created_by" v="BanYaro/1.0"/>'
f'<tag k="comment" v="{note} (dog={value}) — via Ban Yaro"/>'
'<tag k="source" v="survey"/>'
'</changeset></osm>')
def _mark_submitted(contrib_id: int, etype: str, changeset_id):
with db() as conn:
conn.execute(
"UPDATE osm_contributions SET status='submitted', osm_type=?, "
"changeset_id=?, submitted_at=datetime('now') WHERE id=?",
(etype, changeset_id, contrib_id)
)
async def submit_dog_tag(contrib_id: int, osm_id: int, osm_type: str, token: str, value: str) -> bool:
"""Setzt dog=<value> (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:
# 1) Element holen (node/way auto-detect)
elem_xml = etype = None
for t in order:
r = await client.get(f"{OSM_API_BASE}/api/0.6/{t}/{osm_id}", headers=headers)
if r.status_code == 200:
elem_xml, etype = r.text, t
break
if elem_xml is None:
raise RuntimeError(f"OSM-Element {osm_id} nicht gefunden")
root = ET.fromstring(elem_xml)
el = root.find(etype)
existing = el.find("./tag[@k='dog']")
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(value))
cs.raise_for_status()
changeset_id = cs.text.strip()
# 3) dog=<value> setzen + Element hochladen (Geometrie/andere Tags bleiben)
if existing is not None:
existing.set("v", value)
else:
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"))
up.raise_for_status()
# 4) Changeset schließen
await client.put(f"{OSM_API_BASE}/api/0.6/changeset/{changeset_id}/close",
headers=headers)
_mark_submitted(contrib_id, etype, int(changeset_id))
return True
@router.post('/dog-friendly')
async def mark_dog_friendly(body: DogFriendlyIn, user=Depends(get_current_user)):
uid = user['id']
with db() as conn:
# 0) OSM verknüpft?
if not conn.execute("SELECT 1 FROM user_osm WHERE user_id=?", (uid,)).fetchone():
raise HTTPException(409, "Bitte zuerst dein OSM-Konto verknüpfen.")
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()
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(
"SELECT COUNT(*) FROM osm_contributions "
"WHERE user_id=? AND created_at > datetime('now','-1 day')",
(uid,)
).fetchone()[0]
if today_n >= DAILY_CAP:
raise HTTPException(429, "Tageslimit erreicht — morgen geht's weiter.")
# 3) GPS-Beleg: kürzliche Tour, die am POI vorbeiführt (+ Verweil-Proxy)
routes = conn.execute(
"SELECT id, gps_track FROM routes "
"WHERE user_id=? AND created_at > datetime('now', ?) ORDER BY created_at DESC",
(uid, f'-{ROUTE_RECENCY_H} hours')
).fetchall()
best = None # (route_id, min_dist, points_near)
for r in routes:
try:
track = json.loads(r['gps_track'])
except Exception:
continue
near, mind = 0, float('inf')
for p in track:
d = haversine_m(body.lat, body.lon, p['lat'], p['lon'])
if d < mind:
mind = d
if d <= GPS_RADIUS_M:
near += 1
if mind <= GPS_RADIUS_M and near >= DWELL_MIN_POINTS:
if best is None or mind < best[1]:
best = (r['id'], mind, near)
if not best:
raise HTTPException(
422,
"Kein GPS-Beleg: In deinen letzten Touren ist kein Besuch an diesem Ort. "
"Geh mit deinem Hund dorthin, dann kannst du ihn eintragen."
)
# 4) Positions-Sanity gegen die bekannte POI-Koordinate
poi = conn.execute(
"SELECT lat, lon FROM osm_pois WHERE osm_id=? LIMIT 1", (body.osm_id,)
).fetchone()
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 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,)
).fetchone()[0]
# 6) OSM-Upload best-effort — Fehler → bleibt 'pending', Job versucht erneut
submitted = False
try:
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=%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", "value": value, "verified": True, "submitted": submitted,
"verified_count": total, "badge": total >= BADGE_AT,
"pro_progress": min(total, PRO_AT), "pro_at": PRO_AT,
}
@router.get('/status')
async def contrib_status(user=Depends(get_current_user)):
uid = user['id']
with db() as conn:
total = _verified_count(conn, uid)
by_status = {row[0]: row[1] for row in conn.execute(
"SELECT status, COUNT(*) FROM osm_contributions WHERE user_id=? GROUP BY status",
(uid,)
).fetchall()}
return {
"verified_count": total, "by_status": by_status,
"badge": total >= BADGE_AT,
"pro_progress": min(total, PRO_AT), "pro_at": PRO_AT,
}
# ------------------------------------------------------------------
# Confirm/Revert + Pro-Freischaltung (vom Scheduler-Job aufgerufen)
# ------------------------------------------------------------------
CONFIRM_AFTER_DAYS = 7 # Edit muss so lange in OSM ohne Revert überleben
def _grant_pro_if_earned(uid: int):
"""100 bestätigte Beiträge = 1 Jahr Pro. Idempotent über osm_pro_grants.
HINWEIS: setzt is_premium/subscription_* direkt vor Produktion mit dem
Abo-/Billing-System abgleichen."""
with db() as conn:
confirmed = conn.execute(
"SELECT COUNT(*) FROM osm_contributions WHERE user_id=? AND status='confirmed'",
(uid,)).fetchone()[0]
granted = conn.execute(
"SELECT COUNT(*) FROM osm_pro_grants WHERE user_id=?", (uid,)).fetchone()[0]
for _ in range(confirmed // PRO_AT - granted):
conn.execute("INSERT INTO osm_pro_grants (user_id) VALUES (?)", (uid,))
conn.execute(
"UPDATE users SET is_premium=1, subscription_tier='pro', "
"subscription_expires_at=datetime("
" MAX(COALESCE(subscription_expires_at, datetime('now')), datetime('now')), '+1 year') "
"WHERE id=?", (uid,))
logger.info("OSM-Pro freigeschaltet: user %s (+1 Jahr)", uid)
async def run_confirmation_round():
"""Täglich: (1) hängengebliebene 'pending' erneut hochladen, (2) 'submitted'
nach CONFIRM_AFTER_DAYS auf Revert-Überleben prüfen confirmed|rejected,
(3) Pro-Freischaltung prüfen."""
# (1) Pending-Retry
with db() as conn:
pend = conn.execute(
"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_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, tag_value FROM osm_contributions "
"WHERE status='submitted' AND submitted_at < datetime('now', ?)",
(f"-{CONFIRM_AFTER_DAYS} days",)
).fetchall()
affected = set()
async with httpx.AsyncClient(timeout=15) as client:
for r in subs:
etype = r["osm_type"] or "node"
try:
resp = await client.get(f"{OSM_API_BASE}/api/0.6/{etype}/{r['osm_id']}")
ok = False
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") == r["tag_value"]
new_status = "confirmed" if ok else "rejected"
except Exception:
continue # nächste Runde erneut
with db() as conn:
conn.execute("UPDATE osm_contributions SET status=? WHERE id=?", (new_status, r["id"]))
affected.add(r["user_id"])
# (3) Pro-Freischaltung
for uid in affected:
_grant_pro_if_earned(uid)
if subs or pend:
logger.info("OSM-Confirm-Runde: %d pending-retry, %d geprüft, %d User betroffen",
len(pend), len(subs), len(affected))

View file

@ -186,6 +186,16 @@ def start():
misfire_grace_time=3600,
coalesce=True,
)
# Täglich 03:40 Uhr — OSM-Beiträge: Pending-Retry + Revert-Überleben prüfen
# + Pro-Freischaltung (staggered, ruhige Zeit)
_scheduler.add_job(
_job_osm_confirm,
CronTrigger(hour=3, minute=40),
id="osm_confirm",
replace_existing=True,
misfire_grace_time=3600,
coalesce=True,
)
# Jeden Montag 08:10 Uhr — Neue Foto-Challenge anlegen (staggered weg von 08:00)
_scheduler.add_job(
_job_new_foto_challenge,
@ -1720,6 +1730,16 @@ async def _job_streak_reminder():
# ------------------------------------------------------------------
# JOB: Tierfutter-Rückrufe prüfen (RASFF, täglich 08:00)
# ------------------------------------------------------------------
async def _job_osm_confirm():
"""OSM-Beiträge: Pending-Retry + Revert-Überleben prüfen + Pro-Freischaltung.
Import innen kein Zirkel-Import beim Modul-Load."""
try:
from routes.osm_contrib import run_confirmation_round
await run_confirmation_round()
except Exception as e:
logger.warning("OSM-Confirm-Job Fehler: %s", e)
async def _job_recall_check():
"""
Fragt täglich die RASFF EU-API nach neuen Tierfutter-Rückrufen ab.

View file

@ -86,14 +86,14 @@
<title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1155"></script>
<script src="/js/boot-early.js?v=1159"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1155">
<link rel="stylesheet" href="/css/layout.css?v=1155">
<link rel="stylesheet" href="/css/components.css?v=1155">
<link rel="stylesheet" href="/css/utilities.css?v=1155">
<link rel="stylesheet" href="/css/lists.css?v=1155">
<link rel="stylesheet" href="/css/design-system.css?v=1159">
<link rel="stylesheet" href="/css/layout.css?v=1159">
<link rel="stylesheet" href="/css/components.css?v=1159">
<link rel="stylesheet" href="/css/utilities.css?v=1159">
<link rel="stylesheet" href="/css/lists.css?v=1159">
</head>
<body>
@ -617,11 +617,11 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1155"></script>
<script src="/js/ui.js?v=1155"></script>
<script src="/js/app.js?v=1155"></script>
<script src="/js/worlds.js?v=1155"></script>
<script src="/js/offline-indicator.js?v=1155"></script>
<script src="/js/api.js?v=1159"></script>
<script src="/js/ui.js?v=1159"></script>
<script src="/js/app.js?v=1159"></script>
<script src="/js/worlds.js?v=1159"></script>
<script src="/js/offline-indicator.js?v=1159"></script>
<!-- Feature-Seiten werden lazy geladen -->
@ -631,7 +631,7 @@
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script src="/js/boot.js?v=1155"></script>
<script src="/js/boot.js?v=1159"></script>
</body>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '1155'; // ← 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;

View file

@ -1103,6 +1103,23 @@ window.Page_map = (() => {
? `<button class="btn btn-danger btn-sm" id="mp-action">Löschen</button>`
: `<button class="btn btn-secondary btn-sm" id="mp-action">Als ungültig melden</button>`;
// "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))
? `<div style="margin-bottom:8px">
<div style="font-size:11px;color:#666;margin-bottom:4px">Hund willkommen?</div>
<div style="display:flex;gap:6px">
<button class="btn btn-secondary btn-sm" id="mp-dogyes" style="flex:1" title="Hund willkommen">
<svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#thumbs-up"></use></svg>
</button>
<button class="btn btn-secondary btn-sm" id="mp-dogno" style="flex:1" title="Hund nicht willkommen">
<svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#thumbs-down"></use></svg>
</button>
</div>
</div>`
: '';
const openHours = poi.opening_hours
? `<div style="font-size:11px;color:#555;margin-bottom:4px"><svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#clock"></use></svg> ${poi.opening_hours}</div>` : '';
const phone = poi.phone
@ -1120,7 +1137,7 @@ window.Page_map = (() => {
? `<svg class="ph-icon" aria-hidden="true" style="width:11px;height:11px"><use href="/icons/phosphor.svg#push-pin"></use></svg> Community-Pin${poi.username ? ' · <b>' + poi.username + '</b>' : ''}`
: '<svg class="ph-icon" aria-hidden="true" style="width:11px;height:11px"><use href="/icons/phosphor.svg#map-trifold"></use></svg> OpenStreetMap'}
</div>
${actionBtn}
${dogBtn}${actionBtn}
</div>
`, { maxWidth: 260 }).openPopup();
@ -1130,6 +1147,27 @@ window.Page_map = (() => {
if (isOwn) _deleteUserPoi(poi.user_poi_id, marker, layerKey);
else _showReportDialog(poi);
});
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, welcome,
});
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.');
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);
}

View file

@ -672,6 +672,13 @@ window.Page_settings = (() => {
</div>
</div>
<div class="card mb-4">
<div class="by-card-section-header">OpenStreetMap die Karte mitverbessern</div>
<div id="settings-osm-body" class="p-4">
<div class="text-sm-muted">Lädt</div>
</div>
</div>
<div class="card mb-4">
<div class="card-body" style="padding:0">
<div class="sidebar-item" data-page="dog-profile"
@ -925,6 +932,82 @@ window.Page_settings = (() => {
});
}).catch(() => {});
// OSM-Account-Verknüpfung (Modell A) — Status laden + Buttons verdrahten
(function _osmLink() {
const el = document.getElementById('settings-osm-body');
if (!el) return;
API.get('/osm-auth/status').then(st => {
if (st.linked) {
el.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<svg class="ph-icon" style="color:var(--c-success)" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg>
<span style="font-size:var(--text-sm)">Verknüpft als <strong>${UI.escape(st.osm_name)}</strong></span>
</div>
<div id="settings-osm-count" class="text-sm-muted" style="margin-top:var(--space-3)"></div>
<button id="settings-osm-unlink"
style="margin-top:var(--space-3);background:none;border:none;
color:var(--c-text-muted);font-size:var(--text-xs);cursor:pointer">
Verknüpfung trennen
</button>`;
el.querySelector('#settings-osm-unlink').addEventListener('click', async () => {
try { await API.post('/osm-auth/unlink', {}); } catch (e) {}
_osmLink();
});
// Gamification-Zähler
API.get('/osm-contrib/status').then(cs => {
const c = document.getElementById('settings-osm-count');
if (!c) return;
const n = cs.verified_count || 0;
const next = n >= cs.pro_at ? 0 : (n < 10 ? 10 - n : cs.pro_at - n);
c.innerHTML = `🐾 <strong>${n}</strong> hundefreundliche Orte eingetragen`
+ (next ? ` · noch ${next} bis ${n < 10 ? 'zum Kartograf-Badge' : '1 Jahr Pro'}` : ' · Ziel erreicht! 🎉');
}).catch(() => { const c = document.getElementById('settings-osm-count'); if (c) c.textContent=''; });
} else {
el.innerHTML = `
<p class="text-sm-muted" style="margin:0 0 var(--space-3);line-height:1.45">
Du kennst die hundefreundlichen Orte besser als jede Karte. Verknüpfe deinen
kostenlosen OpenStreetMap-Account und trag mit einem Tap ein, wo dein Hund
willkommen war das hilft jedem Hundehalter nach dir. Kostenlos, gemeinnützig,
keine Werbung.
</p>
<button id="settings-osm-link"
style="display:flex;align-items:center;justify-content:center;gap:var(--space-2);
padding:var(--space-3) var(--space-4);border-radius:var(--radius-md);
border:none;background:var(--c-primary);color:#fff;
font-size:var(--text-sm);font-weight:600;cursor:pointer">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-trifold"></use></svg>
OSM-Konto verknüpfen
</button>
<details style="margin-top:10px">
<summary style="cursor:pointer;font-size:12px;color:var(--c-primary)">Noch kein OSM-Konto? Was ist das?</summary>
<div class="text-sm-muted" style="margin-top:8px;font-size:12px;line-height:1.5">
<p style="margin:0 0 6px">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.</p>
<p style="margin:0 0 4px"><strong>So geht's:</strong></p>
<ol style="margin:0 0 8px 16px;padding:0">
<li>Konto erstellen (Benutzername + E-Mail kein Klarname nötig)</li>
<li>Bestätigungs-E-Mail anklicken</li>
<li>Hier zurück OSM-Konto verknüpfen"</li>
</ol>
${st.sandbox ? `<p style="margin:0 0 8px;padding:6px 8px;background:rgba(245,158,11,.12);border-radius:6px">⚠️ <strong>Testphase:</strong> Dies ist eine Test-Karte. Deine Einträge verändern die echte OpenStreetMap noch nicht.</p>` : ''}
<a href="${st.signup_url || 'https://www.openstreetmap.org/user/new'}" target="_blank" rel="noopener"
style="display:inline-flex;align-items:center;gap:6px;color:var(--c-primary);font-weight:600;text-decoration:none">
Kostenloses OSM-Konto erstellen
<svg class="ph-icon" aria-hidden="true" style="width:13px;height:13px"><use href="/icons/phosphor.svg#arrow-square-out"></use></svg>
</a>
</div>
</details>`;
el.querySelector('#settings-osm-link').addEventListener('click', async () => {
try {
const r = await API.get('/osm-auth/authorize');
if (r.authorize_url) window.location.href = r.authorize_url;
} catch (e) {
UI.toast?.('OSM-Anbindung noch nicht konfiguriert.');
}
});
}
}).catch(() => { el.innerHTML = '<div class="text-sm-muted">OSM-Status nicht verfügbar.</div>'; });
})();
// Achievements laden (Streak + Stats + Badges)
API.get('/achievements/me').then(a => {
const statsEl = document.getElementById('settings-stats-body');

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<script src="/js/landing-init.js?v=1155"></script>
<script src="/js/landing-init.js?v=1159"></script>
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store.">
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">

View file

@ -4,7 +4,7 @@
============================================================ */
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
const VER = '1155';
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