Veranstaltet von ${UI.escape(walk.veranstalter_name || 'Unbekannt')}
@@ -525,6 +567,49 @@ window.Page_walks = (() => {
document.getElementById('wd-close')?.addEventListener('click', UI.modal.close);
+ // Foto-Upload
+ document.getElementById('wd-photo-input')?.addEventListener('change', async function() {
+ if (!this.files.length) return;
+ const file = this.files[0];
+ const formData = new FormData();
+ formData.append('file', file);
+ try {
+ const photo = await API.walks.uploadPhoto(walk.id, formData);
+ const grid = document.getElementById('wd-photos-grid');
+ if (grid) {
+ grid.querySelector('p')?.remove();
+ const div = document.createElement('div');
+ div.style.cssText = 'position:relative;aspect-ratio:1';
+ div.innerHTML = `
+
+ `;
+ grid.appendChild(div);
+ _bindPhotoDel(walk.id, div);
+ UI.toast.success('Foto hochgeladen.');
+ }
+ } catch (err) {
+ UI.toast.error(err.message || 'Fehler beim Hochladen.');
+ }
+ this.value = '';
+ });
+
+ // Foto löschen — alle bestehenden Buttons
+ function _bindPhotoDel(walkId, container) {
+ container.querySelectorAll('.wd-photo-del').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ if (!window.confirm('Foto löschen?')) return;
+ try {
+ await API.walks.deletePhoto(walkId, parseInt(btn.dataset.photoId));
+ btn.closest('[style*="aspect-ratio"]')?.remove();
+ UI.toast.success('Foto gelöscht.');
+ } catch (err) { UI.toast.error(err.message || 'Fehler.'); }
+ });
+ });
+ }
+ _bindPhotoDel(walk.id, document);
+
document.getElementById('wd-login')?.addEventListener('click', () => {
UI.modal.close();
App.navigate('settings');
diff --git a/backend/static/sw.js b/backend/static/sw.js
index d6ce60f..91c4cd8 100644
--- a/backend/static/sw.js
+++ b/backend/static/sw.js
@@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
-const CACHE_VERSION = 'by-v877';
+const CACHE_VERSION = 'by-v878';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
From 465dc2e4d369b183d7e3e5b19c98a474dcc4b1ca Mon Sep 17 00:00:00 2001
From: rene
Date: Tue, 12 May 2026 17:17:36 +0200
Subject: [PATCH 2/6] =?UTF-8?q?Security:=20Auth-gesch=C3=BCtzte=20Media-En?=
=?UTF-8?q?dpoints=20f=C3=BCr=20Diary+Health,=20Walk-Foto-Naming=20(SW=20b?=
=?UTF-8?q?y-v879)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/main.py | 111 +++++++++++++++++++++++++++++++-------
backend/routes/walks.py | 11 +++-
backend/static/index.html | 14 ++---
backend/static/js/app.js | 2 +-
backend/static/sw.js | 2 +-
5 files changed, 110 insertions(+), 30 deletions(-)
diff --git a/backend/main.py b/backend/main.py
index 1971763..4e8cbd1 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -354,29 +354,100 @@ _MIME_MAP = {
".webm": "video/webm", ".pdf": "application/pdf",
}
-if STAGING and os.path.isdir(PROD_MEDIA_DIR):
- # Staging: eigene Uploads in MEDIA_DIR, Fallback auf Prod-Medien (read-only)
- from fastapi.responses import FileResponse as _FileResponse
+from fastapi import Request as _Request
+from fastapi.responses import FileResponse as _FileResponse
+from auth import decode_token as _decode_token
- def _media_response(filepath: str):
- ext = os.path.splitext(filepath)[1].lower()
- mt = _MIME_MAP.get(ext, "application/octet-stream")
- return _FileResponse(filepath, media_type=mt)
+# Pfade die Login erfordern (Eigentümer-Check)
+_OWNER_PROTECTED = ("diary/", "health/")
+# Pfade die nur Login erfordern (kein Eigentümer-Check nötig)
+_AUTH_ONLY = ("walks/",)
- @app.api_route("/media/{path:path}", methods=["GET", "HEAD"])
- async def serve_media_staging(path: str):
- staging_file = os.path.join(MEDIA_DIR, path)
- if os.path.isfile(staging_file):
- return _media_response(staging_file)
- prod_file = os.path.join(PROD_MEDIA_DIR, path)
- if os.path.isfile(prod_file):
- return _media_response(prod_file)
- from fastapi import HTTPException as _HE
- raise _HE(404, "Media not found")
-else:
- app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
-APP_VER = "878" # muss mit APP_VER in app.js übereinstimmen
+def _uid_from_request(request: _Request):
+ token = request.cookies.get("by_token")
+ if not token:
+ return None
+ try:
+ return int(_decode_token(token)["sub"])
+ except Exception:
+ return None
+
+
+def _media_response(filepath: str):
+ ext = os.path.splitext(filepath)[1].lower()
+ mt = _MIME_MAP.get(ext, "application/octet-stream")
+ return _FileResponse(filepath, media_type=mt,
+ headers={"Cache-Control": "private, max-age=3600"})
+
+
+def _resolve_media_path(path: str) -> str | None:
+ """Gibt den echten Dateipfad zurück — Staging sucht zuerst lokal, dann Prod."""
+ primary = os.path.join(MEDIA_DIR, path)
+ if os.path.isfile(primary):
+ return primary
+ if STAGING and os.path.isdir(PROD_MEDIA_DIR):
+ fallback = os.path.join(PROD_MEDIA_DIR, path)
+ if os.path.isfile(fallback):
+ return fallback
+ return None
+
+
+@app.api_route("/media/{path:path}", methods=["GET", "HEAD"])
+async def serve_media(path: str, request: _Request):
+ from fastapi import HTTPException as _HE
+
+ prefix = path.split("/")[0] + "/"
+ filename = path.split("/", 1)[1] if "/" in path else path
+
+ # Auth-Pflicht für geschützte Pfade
+ if prefix in _OWNER_PROTECTED or prefix in _AUTH_ONLY:
+ uid = _uid_from_request(request)
+ if not uid:
+ raise _HE(401, "Anmeldung erforderlich.")
+
+ if prefix in _OWNER_PROTECTED:
+ # Eigentümer-Check: Datei muss zum eingeloggten User gehören
+ # Preview-Dateien (foo_preview.webp) → Basis-Stem suchen
+ stem = filename.rsplit("_preview", 1)[0] if "_preview" in filename else filename.rsplit(".", 1)[0]
+ with db() as conn:
+ if prefix == "diary/":
+ row = conn.execute("""
+ SELECT dm.id FROM diary_media dm
+ JOIN diary d ON d.id = dm.diary_id
+ JOIN dogs dog ON dog.id = d.dog_id
+ LEFT JOIN dog_shares ds
+ ON ds.dog_id = dog.id AND ds.shared_with_id = ?
+ AND ds.accepted_at IS NOT NULL
+ WHERE (dog.user_id = ? OR ds.id IS NOT NULL)
+ AND dm.url LIKE ?
+ LIMIT 1
+ """, (uid, uid, f'/media/diary/{stem}%')).fetchone()
+ else: # health/
+ row = conn.execute("""
+ SELECT hm.id FROM health_media hm
+ JOIN health h ON h.id = hm.health_id
+ JOIN dogs dog ON dog.id = h.dog_id
+ WHERE dog.user_id = ? AND hm.url LIKE ?
+ LIMIT 1
+ """, (uid, f'/media/health/{stem}%')).fetchone()
+ # Fallback: dokument_url (alte Einzel-Uploads)
+ if not row:
+ row = conn.execute("""
+ SELECT h.id FROM health h
+ JOIN dogs dog ON dog.id = h.dog_id
+ WHERE dog.user_id = ? AND h.dokument_url LIKE ?
+ LIMIT 1
+ """, (uid, f'/media/health/{stem}%')).fetchone()
+ if not row:
+ raise _HE(403, "Zugriff verweigert.")
+
+ filepath = _resolve_media_path(path)
+ if not filepath:
+ raise _HE(404, "Nicht gefunden.")
+ return _media_response(filepath)
+
+APP_VER = "879" # muss mit APP_VER in app.js übereinstimmen
@app.get("/.well-known/assetlinks.json")
async def assetlinks():
diff --git a/backend/routes/walks.py b/backend/routes/walks.py
index 03074f3..3a0c48b 100644
--- a/backend/routes/walks.py
+++ b/backend/routes/walks.py
@@ -576,7 +576,16 @@ async def upload_walk_photo(
except Exception:
pass
- filename = f"walk_{walk_id}_{uuid.uuid4().hex[:8]}.jpg"
+ import re as _re
+ walk_datum = walk['datum'] or "0000-00-00" # YYYY-MM-DD
+ uname_raw = (user.get('name') or 'user').lower()
+ uname_safe = _re.sub(r'[^a-z0-9]', '-', uname_raw)[:20].strip('-')
+ with db() as conn:
+ count = conn.execute(
+ "SELECT COUNT(*) FROM walk_photos WHERE walk_id=? AND user_id=?",
+ (walk_id, user['id'])
+ ).fetchone()[0]
+ filename = f"{walk_datum}-{uname_safe}-{count + 1:03d}.jpg"
path = os.path.join(MEDIA_DIR, "walks", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
diff --git a/backend/static/index.html b/backend/static/index.html
index 10c0040..5b09df8 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 759a280..82f2ac3 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 = '878'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
+const APP_VER = '879'; // ← 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/sw.js b/backend/static/sw.js
index 91c4cd8..1cff854 100644
--- a/backend/static/sw.js
+++ b/backend/static/sw.js
@@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
-const CACHE_VERSION = 'by-v878';
+const CACHE_VERSION = 'by-v879';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
From bf1087c5e1e0ac5b39526c58dffae583f6015c21 Mon Sep 17 00:00:00 2001
From: rene
Date: Tue, 12 May 2026 17:28:16 +0200
Subject: [PATCH 3/6] =?UTF-8?q?Feature+Security:=20DSGVO-Datenexport,=20au?=
=?UTF-8?q?th-gesch=C3=BCtzte=20Media,=20Datenschutzerkl=C3=A4rung=20v2=20?=
=?UTF-8?q?(SW=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.