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.
This commit is contained in:
rene 2026-06-03 21:20:32 +02:00
parent 46caa05020
commit 1cfaa0264f
4 changed files with 186 additions and 0 deletions

View file

@ -368,6 +368,30 @@ def init_db():
linked_at TEXT NOT NULL DEFAULT (datetime('now')) 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 -- VERLORENE HUNDE
CREATE TABLE IF NOT EXISTS lost_dogs ( CREATE TABLE IF NOT EXISTS lost_dogs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,

View file

@ -228,6 +228,7 @@ from routes.events import router as events_router
from routes.sitting import router as sitting_router from routes.sitting import router as sitting_router
from routes.osm import router as osm_router from routes.osm import router as osm_router
from routes.osm_auth import router as osm_auth_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.forum import router as forum_router
from routes.lost import router as lost_router from routes.lost import router as lost_router
from routes.knigge import router as knigge_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(sitting_router, prefix="/api/sitting", tags=["Sitting"])
app.include_router(osm_router, prefix="/api/osm", tags=["OSM"]) 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_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(weather_router, prefix="/api/weather", tags=["Wetter"])
app.include_router(social_router, prefix="/api/social", tags=["Social"]) app.include_router(social_router, prefix="/api/social", tags=["Social"])
app.include_router(forum_router, prefix="/api/forum", tags=["Forum"]) app.include_router(forum_router, prefix="/api/forum", tags=["Forum"])

View file

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

View file

@ -943,6 +943,7 @@ window.Page_settings = (() => {
<svg class="ph-icon" style="color:var(--c-success)" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg> <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> <span style="font-size:var(--text-sm)">Verknüpft als <strong>${UI.escape(st.osm_name)}</strong></span>
</div> </div>
<div id="settings-osm-count" class="text-sm-muted" style="margin-top:var(--space-3)"></div>
<button id="settings-osm-unlink" <button id="settings-osm-unlink"
style="margin-top:var(--space-3);background:none;border:none; style="margin-top:var(--space-3);background:none;border:none;
color:var(--c-text-muted);font-size:var(--text-xs);cursor:pointer"> color:var(--c-text-muted);font-size:var(--text-xs);cursor:pointer">
@ -952,6 +953,15 @@ window.Page_settings = (() => {
try { await API.post('/osm-auth/unlink', {}); } catch (e) {} try { await API.post('/osm-auth/unlink', {}); } catch (e) {}
_osmLink(); _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 { } else {
el.innerHTML = ` el.innerHTML = `
<p class="text-sm-muted" style="margin:0 0 var(--space-3);line-height:1.45"> <p class="text-sm-muted" style="margin:0 0 var(--space-3);line-height:1.45">