From 44ba51cd38b7715200c31f4bfe6cfecdbacdbc20 Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 12 May 2026 17:04:43 +0200 Subject: [PATCH 1/6] Feature: Gassi-Hundefotos bei Teilnehmern + Fotos nach dem Treffen (SW by-v878) --- backend/database.py | 16 +++++ backend/main.py | 2 +- backend/routes/walks.py | 112 ++++++++++++++++++++++++++++++- backend/static/index.html | 14 ++-- backend/static/js/api.js | 3 + backend/static/js/app.js | 2 +- backend/static/js/pages/walks.js | 99 +++++++++++++++++++++++++-- backend/static/sw.js | 2 +- 8 files changed, 230 insertions(+), 20 deletions(-) diff --git a/backend/database.py b/backend/database.py index 50f50ee..3d9757f 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2210,6 +2210,22 @@ def _migrate(conn_factory): except Exception: pass + # Gassi-Treffen Fotos + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS walk_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + walk_id INTEGER NOT NULL REFERENCES walks(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + url TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_walk_photos_walk ON walk_photos(walk_id)") + logger.info("Migration: walk_photos bereit.") + except Exception as e: + logger.warning(f"Migration walk_photos: {e}") + # Versicherungs-Verwaltung try: conn.execute(""" diff --git a/backend/main.py b/backend/main.py index 581b849..1971763 100644 --- a/backend/main.py +++ b/backend/main.py @@ -376,7 +376,7 @@ if STAGING and os.path.isdir(PROD_MEDIA_DIR): else: app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") -APP_VER = "877" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "878" # 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 85bea3d..03074f3 100644 --- a/backend/routes/walks.py +++ b/backend/routes/walks.py @@ -1,15 +1,17 @@ """BAN YARO — Gassi-Treffen""" -import math +import math, os, uuid import httpx from datetime import date -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from pydantic import BaseModel from typing import Optional, List from database import db from auth import get_current_user from routes.push import send_push_to_user +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") + router = APIRouter() @@ -371,9 +373,34 @@ async def get_walk(walk_id: int): GROUP BY wp.user_id """, (walk_id,)).fetchall() + # Hunde-Details (Foto + Rasse) pro Teilnehmer + dog_rows = conn.execute(""" + SELECT wpd.user_id, d.name AS dog_name, d.foto_url, d.rasse + FROM walk_participant_dogs wpd + JOIN dogs d ON d.id = wpd.dog_id + WHERE wpd.walk_id = ? + """, (walk_id,)).fetchall() + + # Walk-Fotos + photos = conn.execute( + "SELECT id, user_id, url, created_at FROM walk_photos WHERE walk_id=? ORDER BY created_at", + (walk_id,) + ).fetchall() + + from collections import defaultdict + dogs_by_user = defaultdict(list) + for r in dog_rows: + dogs_by_user[r['user_id']].append({ + 'name': r['dog_name'], 'foto_url': r['foto_url'], 'rasse': r['rasse'] + }) + result = dict(walk) - result['teilnehmer'] = [dict(p) for p in participants] + result['teilnehmer'] = [ + {**dict(p), 'hunde_liste': dogs_by_user.get(p['user_id'], [])} + for p in participants + ] result['teilnehmer_count'] = len(result['teilnehmer']) + result['photos'] = [dict(p) for p in photos] return result @@ -508,3 +535,82 @@ async def leave_walk(walk_id: int, user=Depends(get_current_user)): conn.execute("UPDATE walks SET status = 'offen' WHERE id = ?", (walk_id,)) return {"status": "left", "teilnehmer_count": count} + + +# ------------------------------------------------------------------ +# POST /api/walks/{id}/photos — Foto nach dem Treffen hochladen +# GET /api/walks/{id}/photos — Fotos eines Treffens abrufen +# ------------------------------------------------------------------ +@router.post("/{walk_id}/photos", status_code=201) +async def upload_walk_photo( + walk_id: int, + file: UploadFile = File(...), + user=Depends(get_current_user) +): + with db() as conn: + walk = conn.execute("SELECT * FROM walks WHERE id=?", (walk_id,)).fetchone() + if not walk: + raise HTTPException(404, "Treffen nicht gefunden.") + # Nur Teilnehmer oder Veranstalter dürfen Fotos hochladen + is_participant = conn.execute( + "SELECT 1 FROM walk_participants WHERE walk_id=? AND user_id=?", + (walk_id, user['id']) + ).fetchone() + if not is_participant and walk['user_id'] != user['id']: + raise HTTPException(403, "Nur Teilnehmer können Fotos hochladen.") + + import io + from PIL import Image, ImageOps + try: + import pillow_heif; pillow_heif.register_heif_opener() + except ImportError: + pass + + raw = await file.read() + try: + img = Image.open(io.BytesIO(raw)) + img = ImageOps.exif_transpose(img).convert("RGB") + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=88) + raw = buf.getvalue() + except Exception: + pass + + filename = f"walk_{walk_id}_{uuid.uuid4().hex[:8]}.jpg" + path = os.path.join(MEDIA_DIR, "walks", filename) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + f.write(raw) + + url = f"/media/walks/{filename}" + with db() as conn: + cur = conn.execute( + "INSERT INTO walk_photos (walk_id, user_id, url) VALUES (?,?,?)", + (walk_id, user['id'], url) + ) + row = conn.execute("SELECT * FROM walk_photos WHERE id=?", (cur.lastrowid,)).fetchone() + return dict(row) + + +@router.get("/{walk_id}/photos") +async def get_walk_photos(walk_id: int): + with db() as conn: + rows = conn.execute( + "SELECT * FROM walk_photos WHERE walk_id=? ORDER BY created_at", + (walk_id,) + ).fetchall() + return [dict(r) for r in rows] + + +@router.delete("/{walk_id}/photos/{photo_id}", status_code=204) +async def delete_walk_photo(walk_id: int, photo_id: int, user=Depends(get_current_user)): + with db() as conn: + photo = conn.execute( + "SELECT * FROM walk_photos WHERE id=? AND walk_id=?", (photo_id, walk_id) + ).fetchone() + if not photo: + raise HTTPException(404) + walk = conn.execute("SELECT user_id FROM walks WHERE id=?", (walk_id,)).fetchone() + if photo['user_id'] != user['id'] and walk['user_id'] != user['id']: + raise HTTPException(403) + conn.execute("DELETE FROM walk_photos WHERE id=?", (photo_id,)) diff --git a/backend/static/index.html b/backend/static/index.html index eca24fc..10c0040 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/api.js b/backend/static/js/api.js index b383d66..7a341a1 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -346,6 +346,9 @@ const API = (() => { invite(id, friendId) { return post(`/walks/${id}/invite`, { friend_id: friendId }); }, rsvp(id, status) { return post(`/walks/${id}/rsvp`, { status }); }, participants(id) { return get(`/walks/${id}/participants`); }, + photos(id) { return get(`/walks/${id}/photos`); }, + uploadPhoto(id, formData) { return upload(`/walks/${id}/photos`, formData); }, + deletePhoto(id, photoId) { return del(`/walks/${id}/photos/${photoId}`); }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 76478d3..759a280 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 = '877'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '878'; // ← 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/walks.js b/backend/static/js/pages/walks.js index f08d5e9..2f2fab5 100644 --- a/backend/static/js/pages/walks.js +++ b/backend/static/js/pages/walks.js @@ -393,14 +393,31 @@ window.Page_walks = (() => { const isInvited = !!myRsvp; const invitations = participantData?.invitations ?? []; - // Teilnehmerliste (join-Teilnehmer, klassisch) + // Teilnehmerliste mit Hundefotos const teilnehmerHTML = walk.teilnehmer?.length - ? walk.teilnehmer.map(t => ` -
-
${_avatarInitials(t.user_name)}
- ${UI.escape(t.user_name)} - ${t.hunde ? `${UI.icon('dog')} ${UI.escape(t.hunde)}` : ''} -
`).join('') + ? walk.teilnehmer.map(t => { + const dogsHTML = (t.hunde_liste || []).map(d => { + const av = d.foto_url + ? `${UI.escape(d.name)}` + : `
+ +
`; + return `
+ ${av} + ${UI.escape(d.name)}${d.rasse ? ` · ${UI.escape(d.rasse)}` : ''} +
`; + }).join(''); + return ` +
+
${_avatarInitials(t.user_name)}
+
+
${UI.escape(t.user_name)}
+ ${dogsHTML ? `
${dogsHTML}
` : ''} +
+
`; + }).join('') : ''; // Einladungsliste @@ -468,6 +485,31 @@ window.Page_walks = (() => {
+ +
+
+ + ${(isPast || _isToday(walk.datum)) && (isJoined || isOwn) ? ` + ` : ''} +
+
+ ${(walk.photos || []).length === 0 + ? `

Noch keine Fotos.

` + : (walk.photos || []).map(p => ` +
+ + ${p.user_id === _appState.user?.id || isOwn ? ` + + ` : ''} +
`).join('')} +
+
+

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.

`)} - ${sec('Wetterdaten (Open-Meteo)', ` + ${sec('Wetterdaten & Kartendienste', `

- 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. +

+

+ Datenschutzerklärung Open-Meteo: open-meteo.com/en/terms + style="${S.a}">open-meteo.com/en/terms · + OpenWeatherMap: + openweathermap.org/privacy-policy · + OpenStreetMap/Nominatim: + osmfoundation.org

`)} ${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.

`)}

- Stand: Mai 2026 + Stand: Mai 2026 · Version 2

diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 73e8ee5..8767cc6 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -294,6 +294,15 @@ window.Page_settings = (() => { Abmelden +