Compare commits
7 commits
4bc7454258
...
684ffa3b46
| Author | SHA1 | Date | |
|---|---|---|---|
| 684ffa3b46 | |||
| b4cafc0363 | |||
| 9afbf24535 | |||
| 57849515ea | |||
| dc9c0d2cc0 | |||
| 1cfaa0264f | |||
| 46caa05020 |
13 changed files with 710 additions and 17 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1155
|
||||
1159
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
179
backend/routes/osm_auth.py
Normal 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"}
|
||||
324
backend/routes/osm_contrib.py
Normal file
324
backend/routes/osm_contrib.py
Normal 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))
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue