Feature+Security: DSGVO-Datenexport, auth-geschützte Media, Datenschutzerklärung v2 (SW by-v880)

This commit is contained in:
rene 2026-05-12 17:28:16 +02:00
parent 465dc2e4d3
commit bf1087c5e1
7 changed files with 264 additions and 27 deletions

View file

@ -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():

View file

@ -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}"'},
)

View file

@ -101,9 +101,9 @@
</script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=879">
<link rel="stylesheet" href="/css/layout.css?v=879">
<link rel="stylesheet" href="/css/components.css?v=879">
<link rel="stylesheet" href="/css/design-system.css?v=880">
<link rel="stylesheet" href="/css/layout.css?v=880">
<link rel="stylesheet" href="/css/components.css?v=880">
</head>
<body>
@ -583,10 +583,10 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=879"></script>
<script src="/js/ui.js?v=879"></script>
<script src="/js/app.js?v=879"></script>
<script src="/js/worlds.js?v=879"></script>
<script src="/js/api.js?v=880"></script>
<script src="/js/ui.js?v=880"></script>
<script src="/js/app.js?v=880"></script>
<script src="/js/worlds.js?v=880"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

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

View file

@ -102,13 +102,20 @@ window.Page_datenschutz = (() => {
<p style="${S.p};margin-top:var(--space-3)">
Als Ausweichlösung bei Nichtverfügbarkeit des lokalen Modells wird
<strong>Claude Sonnet 4.6</strong> 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:
<a href="https://www.anthropic.com/privacy" target="_blank" rel="noopener"
style="${S.a}">anthropic.com/privacy</a>.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Die <strong>Rassenerkennung per Foto</strong> 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.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Der <strong>KI-Trainer</strong> analysiert deinen bisherigen Trainingsfortschritt
(Übungshistorie, Erfolgsquoten, Streaks) und gibt personalisierte Empfehlungen.
@ -122,19 +129,31 @@ window.Page_datenschutz = (() => {
findet nicht statt.
</p>`)}
${sec('Wetterdaten (Open-Meteo)', `
${sec('Wetterdaten & Kartendienste', `
<p style="${S.p}">
Die Wetter-Funktion übermittelt auf Wunsch deine GPS-Koordinaten einmalig an
<strong>Open-Meteo</strong> (Ö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
<strong>Open-Meteo</strong> (Österreich, DSGVO-konform) für die Wettervorhersage.
Für Wetter-Kartenlayer (Regenradar, Temperaturen) werden Kacheln von
<strong>OpenWeatherMap</strong> (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.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Datenschutzerklärung von Open-Meteo:
Für die automatische Ortsnamens-Ermittlung (z. B. im Wetter-Detail) werden deine
GPS-Koordinaten serverseitig an <strong>Nominatim</strong> der OpenStreetMap Foundation
(UK) übermittelt. Es werden ausschließlich Koordinaten weitergegeben keine
personenbezogenen Daten.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Datenschutzerklärung Open-Meteo:
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
style="${S.a}">open-meteo.com/en/terms</a>
style="${S.a}">open-meteo.com/en/terms</a> ·
OpenWeatherMap:
<a href="https://openweathermap.org/privacy-policy" target="_blank" rel="noopener"
style="${S.a}">openweathermap.org/privacy-policy</a> ·
OpenStreetMap/Nominatim:
<a href="https://osmfoundation.org/wiki/Privacy_Policy" target="_blank" rel="noopener"
style="${S.a}">osmfoundation.org</a>
</p>`)}
${sec('Routenvorschläge (OpenRouteService)', `
@ -200,7 +219,16 @@ window.Page_datenschutz = (() => {
(Art. 16), <strong>Löschung</strong> (Art. 17), <strong>Einschränkung der Verarbeitung</strong>
(Art. 18) sowie <strong>Datenportabilität</strong> (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
</p>
<p style="${S.p};margin-top:var(--space-3)">
<strong>Datenexport (Art. 20 DSGVO):</strong> 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.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Zur Ausübung weiterer Rechte wende dich per E-Mail an
<a href="mailto:hallo@banyaro.app" style="${S.a}">hallo@banyaro.app</a>.<br><br>
Du hast außerdem das Recht, bei der zuständigen Datenschutz-Aufsichtsbehörde
Beschwerde einzulegen:<br>
@ -212,14 +240,14 @@ window.Page_datenschutz = (() => {
${sec('Speicherdauer', `
<p style="${S.p}">
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.
</p>`)}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
Stand: Mai 2026
Stand: Mai 2026 · Version 2
</p>
</div>

View file

@ -294,6 +294,15 @@ window.Page_settings = (() => {
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
Abmelden
</button>
<button id="settings-export-btn"
style="width:100%;margin-top:var(--space-2);display:flex;align-items:center;justify-content:center;
gap:var(--space-2);padding:var(--space-2) var(--space-4);
border-radius:var(--radius-md);border:none;
background:none;color:var(--c-text-secondary);
font-size:var(--text-xs);cursor:pointer">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#download-simple"></use></svg>
Meine Daten exportieren (DSGVO Art. 20)
</button>
<button id="settings-delete-account-btn"
style="width:100%;margin-top:var(--space-2);display:flex;align-items:center;justify-content:center;
gap:var(--space-2);padding:var(--space-2) var(--space-4);
@ -892,6 +901,32 @@ window.Page_settings = (() => {
_render();
});
document.getElementById('settings-export-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('settings-export-btn');
await UI.asyncButton(btn, async () => {
try {
const resp = await fetch('/api/profile/export', {
credentials: 'include',
headers: { 'Authorization': `Bearer ${localStorage.getItem('by_token') || ''}` },
});
if (!resp.ok) throw new Error('Export fehlgeschlagen.');
const data = await resp.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `banyaro-export-${new Date().toISOString().slice(0,10)}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
UI.toast.success('Export heruntergeladen.');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Export.');
}
});
});
document.getElementById('settings-delete-account-btn')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Konto unwiderruflich löschen?',

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v879';
const CACHE_VERSION = 'by-v880';
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