From bf1087c5e1e0ac5b39526c58dffae583f6015c21 Mon Sep 17 00:00:00 2001
From: rene
Date: Tue, 12 May 2026 17:28:16 +0200
Subject: [PATCH] =?UTF-8?q?Feature+Security:=20DSGVO-Datenexport,=20auth-g?=
=?UTF-8?q?esch=C3=BCtzte=20Media,=20Datenschutzerkl=C3=A4rung=20v2=20(SW?=
=?UTF-8?q?=20by-v880)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/main.py | 2 +-
backend/routes/profile.py | 174 +++++++++++++++++++++++++
backend/static/index.html | 14 +-
backend/static/js/app.js | 2 +-
backend/static/js/pages/datenschutz.js | 62 ++++++---
backend/static/js/pages/settings.js | 35 +++++
backend/static/sw.js | 2 +-
7 files changed, 264 insertions(+), 27 deletions(-)
diff --git a/backend/main.py b/backend/main.py
index 4e8cbd1..c975351 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -447,7 +447,7 @@ async def serve_media(path: str, request: _Request):
raise _HE(404, "Nicht gefunden.")
return _media_response(filepath)
-APP_VER = "879" # muss mit APP_VER in app.js übereinstimmen
+APP_VER = "880" # muss mit APP_VER in app.js übereinstimmen
@app.get("/.well-known/assetlinks.json")
async def assetlinks():
diff --git a/backend/routes/profile.py b/backend/routes/profile.py
index 783951f..587fff5 100644
--- a/backend/routes/profile.py
+++ b/backend/routes/profile.py
@@ -167,3 +167,177 @@ async def delete_account(user=Depends(get_current_user)):
conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
return {"status": "deleted"}
+
+
+# ----------------------------------------------------------
+# GET /profile/export — DSGVO Datenexport (Art. 20)
+# ----------------------------------------------------------
+@router.get('/export')
+async def export_user_data(user=Depends(get_current_user)):
+ """Gibt alle personenbezogenen Daten des Users als JSON zurück."""
+ import json as _json
+ from datetime import datetime as _dt
+ from fastapi.responses import Response as _Response
+ uid = user['id']
+
+ with db() as conn:
+ # --- Nutzerprofil ---
+ u = dict(conn.execute(
+ "SELECT id, name, email, bio, wohnort, erfahrung, social_link, "
+ "email_verified, is_premium, subscription_tier, created_at "
+ "FROM users WHERE id=?", (uid,)
+ ).fetchone() or {})
+
+ # --- Hunde ---
+ dogs_raw = conn.execute(
+ "SELECT * FROM dogs WHERE user_id=?", (uid,)
+ ).fetchall()
+ dogs_out = []
+
+ for dog in dogs_raw:
+ did = dog['id']
+ d = dict(dog)
+
+ # Tagebuch
+ diary_rows = conn.execute(
+ "SELECT id, datum, typ, titel, text, gps_lat, gps_lon, "
+ "location_name, is_milestone, created_at FROM diary WHERE dog_id=?",
+ (did,)
+ ).fetchall()
+ diary_out = []
+ for de in diary_rows:
+ de_dict = dict(de)
+ media = conn.execute(
+ "SELECT url, preview_url, media_type FROM diary_media WHERE diary_id=?",
+ (de['id'],)
+ ).fetchall()
+ de_dict['media'] = [dict(m) for m in media]
+ diary_out.append(de_dict)
+
+ # Gesundheit
+ health_rows = conn.execute(
+ "SELECT id, typ, bezeichnung, datum, naechstes, notiz, "
+ "schweregrad, reaktion, dosierung, haeufigkeit, "
+ "tierarzt_name, charge_nr FROM health WHERE dog_id=?",
+ (did,)
+ ).fetchall()
+ health_out = []
+ for he in health_rows:
+ he_dict = dict(he)
+ media = conn.execute(
+ "SELECT url, media_type FROM health_media WHERE health_id=?",
+ (he['id'],)
+ ).fetchall()
+ he_dict['media'] = [dict(m) for m in media]
+ health_out.append(he_dict)
+
+ # Trainingsfortschritt
+ progress = conn.execute(
+ "SELECT exercise_id, status, updated_at FROM exercise_progress "
+ "WHERE dog_id=?", (did,)
+ ).fetchall()
+
+ # Ausgaben
+ expenses = conn.execute(
+ "SELECT datum, betrag, kategorie, notiz, is_recurring "
+ "FROM expenses WHERE dog_id=?", (did,)
+ ).fetchall()
+
+ # Verhalten
+ behavior = conn.execute(
+ "SELECT datum, uhrzeit, kategorie, intensitaet, trigger, notiz "
+ "FROM behavior_log WHERE dog_id=?", (did,)
+ ).fetchall()
+
+ # Versicherung
+ insurance = conn.execute(
+ "SELECT anbieter, police_nr, jahresbeitrag, kontakt, ablaufdatum, notizen "
+ "FROM dog_insurance WHERE dog_id=?", (did,)
+ ).fetchall()
+
+ # Ernährungs-Profil
+ ern = conn.execute(
+ "SELECT futter_typ, marke, kcal_tag, portionen, notizen "
+ "FROM futter_profil WHERE dog_id=?", (did,)
+ ).fetchone()
+
+ # Futter-Einträge
+ futter = conn.execute(
+ "SELECT datum, uhrzeit, futter_name, futter_typ, menge_g, notiz "
+ "FROM futter_eintraege WHERE dog_id=?", (did,)
+ ).fetchall()
+
+ # Futter-Reaktionen
+ reaktionen = conn.execute(
+ "SELECT datum, uhrzeit, reaktion_typ, intensitaet, notiz "
+ "FROM futter_reaktionen WHERE dog_id=?", (did,)
+ ).fetchall()
+
+ # Routen (via route_dogs)
+ routes = conn.execute(
+ "SELECT r.name, r.distanz_km, r.gps_track IS NOT NULL AS hat_track, "
+ "date(r.created_at) AS datum "
+ "FROM routes r JOIN route_dogs rd ON rd.route_id=r.id "
+ "WHERE rd.dog_id=?", (did,)
+ ).fetchall()
+
+ d['tagebuch'] = diary_out
+ d['gesundheit'] = [dict(h) for h in health_out]
+ d['trainingsfortschritt'] = [dict(p) for p in progress]
+ d['ausgaben'] = [dict(e) for e in expenses]
+ d['verhaltensprotokoll'] = [dict(b) for b in behavior]
+ d['versicherung'] = [dict(i) for i in insurance]
+ d['ernaehrungsprofil'] = dict(ern) if ern else None
+ d['futter_eintraege'] = [dict(f) for f in futter]
+ d['futter_reaktionen'] = [dict(r) for r in reaktionen]
+ d['routen'] = [dict(r) for r in routes]
+ dogs_out.append(d)
+
+ # --- Forum-Beiträge ---
+ forum = conn.execute(
+ "SELECT ft.title, fp.content, fp.created_at, "
+ "CASE WHEN fp.parent_id IS NULL THEN 'Thread' ELSE 'Antwort' END AS art "
+ "FROM forum_posts fp "
+ "LEFT JOIN forum_threads ft ON ft.id = fp.thread_id "
+ "WHERE fp.user_id=? ORDER BY fp.created_at DESC",
+ (uid,)
+ ).fetchall()
+
+ # --- Gassi-Teilnahmen ---
+ walk_participations = conn.execute(
+ "SELECT w.titel, w.datum, w.uhrzeit, w.ort_name "
+ "FROM walk_participants wp JOIN walks w ON w.id=wp.walk_id "
+ "WHERE wp.user_id=?", (uid,)
+ ).fetchall()
+
+ # --- Gassi-Fotos ---
+ walk_photos = conn.execute(
+ "SELECT wp.url, w.datum AS walk_datum, w.titel AS walk_titel, wp.created_at "
+ "FROM walk_photos wp JOIN walks w ON w.id=wp.walk_id "
+ "WHERE wp.user_id=?", (uid,)
+ ).fetchall()
+
+ # --- Push-Subscriptions (Anzahl, kein raw endpoint) ---
+ push_count = conn.execute(
+ "SELECT COUNT(*) FROM push_subscriptions WHERE user_id=?", (uid,)
+ ).fetchone()[0]
+
+ export = {
+ "export_erstellt": _dt.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "hinweis": "Dieser Export enthält alle personenbezogenen Daten deines Ban-Yaro-Kontos gemäß Art. 20 DSGVO.",
+ "profil": u,
+ "hunde": dogs_out,
+ "forum_beitraege": [dict(f) for f in forum],
+ "gassi_teilnahmen": [dict(w) for w in walk_participations],
+ "gassi_fotos": [dict(p) for p in walk_photos],
+ "push_subscriptions": push_count,
+ }
+
+ content = _json.dumps(export, ensure_ascii=False, indent=2, default=str)
+ today = _dt.utcnow().strftime("%Y-%m-%d")
+ filename = f"banyaro-export-{today}.json"
+ return _Response(
+ content = content,
+ media_type = "application/json",
+ headers = {"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
diff --git a/backend/static/index.html b/backend/static/index.html
index 5b09df8..8711e3b 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -101,9 +101,9 @@
-
-
-
+
+
+
@@ -583,10 +583,10 @@
-
-
-
-
+
+
+
+
diff --git a/backend/static/js/app.js b/backend/static/js/app.js
index 82f2ac3..9c39ece 100644
--- a/backend/static/js/app.js
+++ b/backend/static/js/app.js
@@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
-const APP_VER = '879'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
+const APP_VER = '880'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
diff --git a/backend/static/js/pages/datenschutz.js b/backend/static/js/pages/datenschutz.js
index d2a8af8..425536f 100644
--- a/backend/static/js/pages/datenschutz.js
+++ b/backend/static/js/pages/datenschutz.js
@@ -102,13 +102,20 @@ window.Page_datenschutz = (() => {
Als Ausweichlösung bei Nichtverfügbarkeit des lokalen Modells wird
Claude Sonnet 4.6 von Anthropic, PBC (San Francisco, USA) genutzt.
- In diesem Fall wird ausschließlich der Inhalt deiner Anfrage (Prompt-Text) übermittelt —
- keine Account- oder Profildaten. Die Übermittlung in die USA erfolgt auf Basis der
- EU-Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO).
+ In diesem Fall wird der Inhalt deiner Anfrage übermittelt. Bei Gesundheits- und
+ Ernährungsberichten kann dies Hundedaten (Name, Rasse, Gewicht, Impfhistorie,
+ Medikamente, Allergien) als Teil des Anfragetextes umfassen. Die Übermittlung
+ in die USA erfolgt auf Basis der EU-Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO).
Datenschutzerklärung von Anthropic:
anthropic.com/privacy.
+
+ Die Rassenerkennung per Foto sendet das hochgeladene Bild direkt an
+ Claude von Anthropic (USA) zur Analyse — es gibt hierfür keinen lokalen Fallback.
+ Das Foto wird nicht dauerhaft bei Anthropic gespeichert. Rechtsgrundlage: Einwilligung
+ gem. Art. 6 Abs. 1 lit. a DSGVO durch aktive Nutzung der Funktion.
+
Der KI-Trainer analysiert deinen bisherigen Trainingsfortschritt
(Übungshistorie, Erfolgsquoten, Streaks) und gibt personalisierte Empfehlungen.
@@ -122,19 +129,31 @@ window.Page_datenschutz = (() => {
findet nicht statt.
- Die Wetter-Funktion übermittelt auf Wunsch deine GPS-Koordinaten einmalig an
- Open-Meteo (Österreich, DSGVO-konform), um die lokale
- Wettervorhersage abzurufen. Es werden ausschließlich anonyme Koordinaten übertragen —
- keine Account- oder Profildaten. Open-Meteo protokolliert keine personenbezogenen
- Daten. Die Funktion wird nur aktiv, wenn du deinen Standort im Browser freigibst.
+ Die Wetter-Funktion übermittelt auf Wunsch deine GPS-Koordinaten serverseitig an
+ Open-Meteo (Österreich, DSGVO-konform) für die Wettervorhersage.
+ Für Wetter-Kartenlayer (Regenradar, Temperaturen) werden Kacheln von
+ OpenWeatherMap (OpenWeather Ltd., UK/USA) geladen — dabei wird
+ dein Browser direkt kontaktiert. Es werden keine Account-Daten übermittelt.
Rechtsgrundlage: Einwilligung gem. Art. 6 Abs. 1 lit. a DSGVO.
- Datenschutzerklärung von Open-Meteo:
+ Für die automatische Ortsnamens-Ermittlung (z. B. im Wetter-Detail) werden deine
+ GPS-Koordinaten serverseitig an Nominatim der OpenStreetMap Foundation
+ (UK) übermittelt. Es werden ausschließlich Koordinaten weitergegeben — keine
+ personenbezogenen Daten.
+
`)}
${sec('Routenvorschläge (OpenRouteService)', `
@@ -200,7 +219,16 @@ window.Page_datenschutz = (() => {
(Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung
(Art. 18) sowie Datenportabilität (Art. 20). Erteilte Einwilligungen
kannst du jederzeit mit Wirkung für die Zukunft widerrufen (Art. 7 Abs. 3 DSGVO).
- Zur Ausübung deiner Rechte wende dich per E-Mail an
+
+
+ Datenexport (Art. 20 DSGVO): Du kannst jederzeit unter
+ Einstellungen → „Meine Daten exportieren" eine vollständige Kopie deiner
+ gespeicherten Daten als JSON-Datei herunterladen. Der Export enthält Profildaten,
+ Hundedaten, Tagebuch, Gesundheitseinträge, Trainingsfortschritt, Ausgaben,
+ Verhaltensprotokoll, Forum-Beiträge und Gassi-Teilnahmen.
+
+
+ Zur Ausübung weiterer Rechte wende dich per E-Mail an
hallo@banyaro.app.
Du hast außerdem das Recht, bei der zuständigen Datenschutz-Aufsichtsbehörde
Beschwerde einzulegen:
@@ -212,14 +240,14 @@ window.Page_datenschutz = (() => {
${sec('Speicherdauer', `
- Deine Daten werden gelöscht, sobald du deinen Account löschst. Server-Logs
- werden nach 30 Tagen automatisch gelöscht. Öffentlich gepostete Inhalte
- (Forenbeiträge, Giftköder-Meldungen) bleiben nach Account-Löschung anonymisiert
- erhalten, sofern sie für die Community relevant sind.
+ Deine Daten werden vollständig gelöscht, sobald du deinen Account löschst —
+ einschließlich Tagebuch, Gesundheitseinträge, Fotos, Forenbeiträge und Hundeprofil.
+ Es gibt keine anonymisierte Weiterverarbeitung deiner Inhalte nach Account-Löschung.
+ Server-Logs werden nach 30 Tagen rotiert.