From 1cfaa0264f33e0b9ce77cb76202ff38ae281d428 Mon Sep 17 00:00:00 2001 From: rene Date: Wed, 3 Jun 2026 21:20:32 +0200 Subject: [PATCH] =?UTF-8?q?OSM-Beitr=C3=A4ge:=20dog=3Dyes-Erfassung=20mit?= =?UTF-8?q?=20GPS/Zeit-Anti-Fraud=20+=20Gamification-Z=C3=A4hler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- backend/database.py | 24 +++++ backend/main.py | 2 + backend/routes/osm_contrib.py | 150 ++++++++++++++++++++++++++++ backend/static/js/pages/settings.js | 10 ++ 4 files changed, 186 insertions(+) create mode 100644 backend/routes/osm_contrib.py diff --git a/backend/database.py b/backend/database.py index ef3b9fa..219e52e 100644 --- a/backend/database.py +++ b/backend/database.py @@ -368,6 +368,30 @@ def init_db(): 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); + -- 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 465231d..f4ecac6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -228,6 +228,7 @@ 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 @@ -294,6 +295,7 @@ 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"]) diff --git a/backend/routes/osm_contrib.py b/backend/routes/osm_contrib.py new file mode 100644 index 0000000..a8e00a3 --- /dev/null +++ b/backend/routes/osm_contrib.py @@ -0,0 +1,150 @@ +""" +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 +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from typing import Optional + +from database import db +from auth import get_current_user +from math_utils import haversine_m + +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 + + +def _verified_count(conn, uid: int) -> int: + return conn.execute( + "SELECT COUNT(*) FROM osm_contributions WHERE user_id=? AND status!='rejected'", + (uid,) + ).fetchone()[0] + + +@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.") + + # 1) Dedup — 1× pro POI + if conn.execute( + "SELECT 1 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.") + + # 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 (status pending — OSM-Upload folgt separat) + 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]) + ) + total = _verified_count(conn, uid) + + logger.info("dog=yes erfasst: user %s, osm %s, Tour %s (%.0fm, %d Pkt)", + uid, body.osm_id, best[0], best[1], best[2]) + return { + "status": "erfasst", "verified": True, "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, + } diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index e87d6e0..c7544b9 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -943,6 +943,7 @@ window.Page_settings = (() => { Verknüpft als ${UI.escape(st.osm_name)} +