diff --git a/.gitignore b/.gitignore index e23a514..cbcf3ae 100644 --- a/.gitignore +++ b/.gitignore @@ -13,9 +13,3 @@ __pycache__/ .claude/worktrees/ Ban Yaro - Google Play package/ /unsplash/ - -# Selbst-gehostete Vektor-Tiles (groß, gehören nicht ins Repo) -tiles/build/ -*.pmtiles -*.osm.pbf -*.mbtiles diff --git a/Makefile b/Makefile index 3c0be1b..c16bf3f 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,6 @@ TAR_EXCLUDE := --exclude='.git' \ --exclude='./backend/__pycache__' \ --exclude='./.env' \ --exclude='./*.db' \ - --exclude='./tiles' \ --exclude='./.DS_Store' .PHONY: help deploy deploy-clean staging release sync push restart build stop status \ diff --git a/backend/main.py b/backend/main.py index 453fb98..f4ecac6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -371,13 +371,6 @@ app.mount("/js", StaticFiles(directory=f"{STATIC_DIR}/js"), name="js") app.mount("/icons", StaticFiles(directory=f"{STATIC_DIR}/icons"), name="icons") app.mount("/img", StaticFiles(directory=f"{STATIC_DIR}/img"), name="img") -# Selbst-gehostete Vektor-Tiles (.pmtiles) — liegen im data-Volume, NICHT im Image. -# Starlette FileResponse beherrscht Range-Requests (206) → MapLibre/pmtiles-Protokoll. -# Guard: Mount nur wenn das Verzeichnis existiert (sonst No-Op, z. B. lokal/Prod ohne Tiles). -_TILES_DIR = os.getenv("TILES_DIR", "/data/tiles") -if os.path.isdir(_TILES_DIR): - app.mount("/tiles", StaticFiles(directory=_TILES_DIR), name="tiles") - # User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.) MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") os.makedirs(MEDIA_DIR, exist_ok=True) diff --git a/backend/routes/profile.py b/backend/routes/profile.py index f840ec8..3762413 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -149,74 +149,25 @@ async def put_world_config(body: WorldConfigIn, user=Depends(get_current_user)): # ---------------------------------------------------------- # DELETE /profile/account — Konto unwiderruflich löschen # ---------------------------------------------------------- -# Spalten, die eine HANDLUNG referenzieren (Moderator/Admin/Ersteller), -# nicht Eigentum des Users. Beim Löschen auf NULL setzen statt die fremde -# Zeile (z. B. einen Partner-Code oder eine moderierte Einreichung) mitzureißen. -_ACTOR_COLUMNS = { - ("wiki_foto_submissions", "reviewed_by"), - ("osm_poi_edits", "mod_id"), - ("partner_codes", "created_by"), - ("outreach_log", "sent_by"), - ("upgrade_requests", "fulfilled_by"), -} - - @router.delete('/account') async def delete_account(user=Depends(get_current_user)): - """Löscht das Konto und ALLE zugehörigen Daten unwiderruflich (DSGVO + App-Store-Gl. 4). - - FK-sicher und schema-robust: ermittelt per Introspektion alle Tabellen, die - auf users(id) verweisen. CASCADE-Tabellen werden beim users-DELETE automatisch - geleert; NO-ACTION/RESTRICT-Eigentumstabellen löschen wir explizit; Aktions- - Spalten (Moderator/Admin) setzen wir auf NULL. `defer_foreign_keys` macht die - Reihenfolge irrelevant — geprüft wird erst beim Commit. - """ + """Löscht das Konto und alle zugehörigen Daten unwiderruflich.""" uid = user['id'] with db() as conn: - # FK-Prüfung bis zum Commit aufschieben → Löschreihenfolge egal. - conn.execute("PRAGMA defer_foreign_keys=ON") - - tables = [r['name'] for r in conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" - ).fetchall()] - - # Tabellen merken, deren User-Bezug bereits über eine FK behandelt wurde - # (gelöscht oder genullt), damit der Spalten-Scan sie nicht doppelt anfasst. - handled_fk_cols: set[tuple] = set() - - # --- 1) Formale FKs auf users(id) --- - for tbl in tables: - try: - fks = conn.execute(f"PRAGMA foreign_key_list({tbl})").fetchall() - except Exception: - continue - for fk in fks: - if fk['table'] != 'users': - continue - col = fk['from'] - handled_fk_cols.add((tbl, col)) - on_delete = (fk['on_delete'] or '').upper() - if on_delete == 'CASCADE': - continue # wird durch den finalen users-DELETE mitgelöscht - if on_delete == 'SET NULL' or (tbl, col) in _ACTOR_COLUMNS: - conn.execute(f"UPDATE {tbl} SET {col}=NULL WHERE {col}=?", (uid,)) - else: - # NO ACTION / RESTRICT auf einer Eigentums-Spalte → Zeilen löschen. - conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,)) - - # --- 2) Eigentums-Spalten OHNE formale FK (z. B. events.user_id) --- - # Manche Tabellen tragen user_id/owner_id ohne REFERENCES-Klausel. Die fängt - # die FK-Introspektion nicht — für ein echtes „alle Daten löschen" hier nach. - for tbl in tables: - try: - cols = {r['name'] for r in conn.execute(f"PRAGMA table_info({tbl})").fetchall()} - except Exception: - continue - for col in ('user_id', 'owner_id'): - if col in cols and (tbl, col) not in handled_fk_cols and (tbl, col) not in _ACTOR_COLUMNS: - conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,)) - - # Räumt alle verbliebenen ON-DELETE-CASCADE-Tabellen automatisch ab. + # Alle Hunde-IDs des Users + dog_ids = [r['id'] for r in conn.execute( + "SELECT id FROM dogs WHERE user_id=?", (uid,)).fetchall()] + for did in dog_ids: + conn.execute("DELETE FROM diary WHERE dog_id=?", (did,)) + conn.execute("DELETE FROM health WHERE dog_id=?", (did,)) + conn.execute("DELETE FROM training_sessions WHERE dog_id=?", (did,)) + conn.execute("DELETE FROM training_streaks WHERE dog_id=?", (did,)) + conn.execute("DELETE FROM expenses WHERE dog_id=?", (did,)) + conn.execute("DELETE FROM dogs WHERE user_id=?", (uid,)) + conn.execute("DELETE FROM upgrade_requests WHERE user_id=?", (uid,)) + conn.execute("DELETE FROM push_subscriptions WHERE user_id=?", (uid,)) + conn.execute("DELETE FROM notifications WHERE user_id=?", (uid,)) + conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,)) conn.execute("DELETE FROM users WHERE id=?", (uid,)) return {"status": "deleted"} diff --git a/docs/TILE_SERVER_HANDOVER.md b/docs/TILE_SERVER_HANDOVER.md deleted file mode 100644 index 48657bd..0000000 --- a/docs/TILE_SERVER_HANDOVER.md +++ /dev/null @@ -1,189 +0,0 @@ -# Übergabe: Selbst-gehosteter Tile-Server (Karte + Touren auf eigenen Vektortiles) - -> **An den Kollegen, der das hier im `banyaro`-Repo weiterbaut.** -> Geschrieben aus dem `banyaro-ios`-Kontext heraus, nachdem der App-Store-Resubmit -> (Build 1.0(5)) raus war. Dies ist der **Vorbereitungs- + Staging-Test-Plan**. Bitte -> erst „Kontext & Entscheidung" lesen, dann den Staging-Spike ausführen, dann die -> offenen Punkte mit René klären, bevor irgendwas nach Produktion geht. - ---- - -## 1. Worum geht's - -Wir wollen **Karten selbst hosten** (analog Pocket Earth), statt vom öffentlichen -OSM-Raster-Server zu ziehen. Damit bauen wir **Karte UND Touren** auf eigenen -Vektortiles auf — Web (PWA) und nativ (iOS). - -**Warum überhaupt:** -- **Lizenz/Policy:** Die PWA nutzt aktuell `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png` - (`backend/static/js/ui.js` → `Map.OSM_URL`, `offline-indicator.js`). Die - [OSMF-Tile-Usage-Policy](https://operations.osmfoundation.org/policies/tiles/) - verbietet heavy/kommerzielle Nutzung — auf Dauer kein tragfähiges Fundament. -- **Offline:** iOS soll Regionen offline vorhalten (Pocket-Earth-Modell). Mit eigenen - Tiles ist das sauber machbar, mit dem öffentlichen Raster-Server nicht. -- **Kontrolle & Konsistenz:** Eine Tile-/Style-Quelle für Web + iOS, kein - Drittanbieter-Limit, Retina/Vektor-Styling, eigenes Karten-Design möglich. -- **Companion-Prinzip:** Substanz lebt auf banyaro.app — die Tile-Infrastruktur gehört - genau hierher (DS/Docker/NPM/Staging sind alle in diesem Repo). Die iOS-App ist nur - das native Fenster. - -**Bewusst NICHT Teil des laufenden App-Store-Resubmits** — das ist eigener Scope nach -der Freigabe (andere Risikoklasse: Infra/Ops, Heim-Uplink-Verfügbarkeit; kein offener -Apple-Punkt). Siehe iOS-Memory `project_app_review_build3` / `project_build4_karte`. - ---- - -## 2. Ist-Zustand (im Repo verifiziert) - -- **Karte Web:** Leaflet + `leaflet.markercluster`, Raster-Basemap vom öffentlichen - OSM-Server. Helper in `backend/static/js/ui.js` (`Map.OSM_URL`, `L.tileLayer(...)`), - weitere Vorkommen in `ui.js:1006` und `offline-indicator.js:193`. -- **Karte iOS:** aktuell Apple **MapKit** (im `banyaro-ios`-Repo). Soll später auf - MapLibre Native + Offline-Regionen umgestellt werden — **separater iOS-Workstream**, - dieser Tile-Server ist die Voraussetzung dafür. -- **Deployment:** ein Docker-Service `banyaro` (FastAPI :8000 → DS :3010) hinter - **NPM (Nginx Proxy Manager)**. Static-Files via FastAPI-`StaticFiles`-Mounts - (`/css`, `/js`, `/icons`, `/img` in `backend/main.py:369–372`). Volume `./data:/data`. -- **Staging:** eigener Container `banyaro-staging`, `docker-compose.staging.yml`, - Pfad `/volume1/docker/banyaro-staging`, URL **https://staging.banyaro.app**, - Deploy via `make staging` (pusht `develop`). -- **DS-Zugang:** Host `ds` (10.47.11.10, SSH-Port 4711), `sudo docker`. -- **Tile-Pipeline:** existiert noch **nicht** (keine pbf/mbtiles/pmtiles im Repo). - Es gibt konzeptionell validierte OSM-POI-Pipeline-Notizen (iOS-Memory - `project_build4_pois`) — separat von den Basemap-Tiles, aber gleiche pbf-Quelle. - ---- - -## 3. Empfohlene Architektur: **planetiler → PMTiles → MapLibre** - -Für einen **Heim-Server (DiskStation)** mit **Offline-Anspruch** ist das die klar beste -Wahl — leichter als ein klassischer Raster-Renderer und ohne laufenden Render-Prozess: - -``` -OSM-Extract (.osm.pbf, Geofabrik) - │ planetiler (OpenMapTiles-Schema, einmalig + bei Updates) - ▼ - region.pmtiles ← EIN Single-File-Tile-Archiv (Vektor) - │ per HTTP Range-Requests ausgeliefert (nginx/FastAPI StaticFiles → 206) - ▼ - MapLibre ── Web: MapLibre GL JS + pmtiles-Protokoll (ersetzt Leaflet-Raster) - └─ iOS: MapLibre Native, Offline = .pmtiles lokal auf dem Gerät - + Style-JSON + Glyphs (Fonts) + Sprite (statisch gehostet) - + Touren = GeoJSON-Linien-Layer obendrauf (Basemap-unabhängig) -``` - -**Warum PMTiles (und nicht ein Raster-Tile-Server):** -- **Kein Server-Prozess, kein PostGIS, kein renderd/Mapnik.** Eine Datei, ausgeliefert - per Range-Request — die DS muss zur Laufzeit **nichts rendern**. Ideal für schwache - Hardware + Heim-Uplink. -- **Offline trivial:** dieselbe `.pmtiles` lokal auf dem iPhone → MapLibre liest direkt. - Das ist das Pocket-Earth-Modell. -- **Vektor:** Retina-scharf, eigenes Styling, Labels drehbar, klein. - -**Verworfen — `openstreetmap-tile-server` / renderd+Mapnik (Raster):** schwerer -PostGIS-Import, CPU-Rendering pro Request, große Storage, schlechte Offline-Story, -kein Vektor-Styling. Für unseren Fall strikt unterlegen. - -**Stack-Komponenten:** -| Teil | Tool | Hinweis | -|------|------|---------| -| Tile-Generierung | [planetiler](https://github.com/onthegomap/planetiler) (Docker) | OpenMapTiles-Schema, Output direkt `.pmtiles` | -| Auslieferung | nginx/NPM **oder** FastAPI `StaticFiles` | beide können Range-Requests (206); für Spike reicht StaticFiles | -| Web-Client | `maplibre-gl` + `pmtiles` (JS) | ersetzt Leaflet schrittweise (Feature-Flag) | -| iOS-Client | MapLibre Native (eigener Workstream) | Offline-Region = lokale `.pmtiles` | -| Style | OpenMapTiles-kompatibel (z. B. Positron/OSM-Bright) + Glyphs + Sprite | selbst hosten für volle Unabhängigkeit | - ---- - -## 4. Staging-Spike (konkret, zum Loslegen) - -Ziel: eine kleine Region als `.pmtiles` erzeugen, auf **staging** ausliefern, mit -MapLibre rendern — und Größe/Zeit/Performance/Range-Requests messen. **Erst klein** -(Bayern), nicht gleich DACH. - -### 4.1 Extract + Tiles erzeugen (lokal/Build-Maschine, NICHT auf der DS) -```bash -mkdir -p tiles/build && cd tiles/build -# planetiler lädt den Geofabrik-Extract selbst und schreibt direkt PMTiles: -docker run --rm -v "$PWD:/data" ghcr.io/onthegomap/planetiler:latest \ - --download --area=bayern --output=/data/bayern.pmtiles -# Ergebnis: bayern.pmtiles (grobe Schätzung: paar hundert MB, wenige Minuten) -``` -> DACH gibt es bei Geofabrik nicht als ein Extract → später `germany` + `austria` + -> `switzerland` per `osmium merge` zusammenführen, dann planetiler darauf. Für den -> Spike reicht `bayern`. - -### 4.2 Auf Staging ausliefern (einfachster Weg: FastAPI StaticFiles vom data-Volume) -Die große Datei NICHT ins Image bauen — ins `./data`-Volume legen und mounten. -```python -# backend/main.py — nahe der bestehenden StaticFiles-Mounts (Z. 369ff) -import os -_TILES_DIR = os.getenv("TILES_DIR", "/data/tiles") -if os.path.isdir(_TILES_DIR): - app.mount("/tiles", StaticFiles(directory=_TILES_DIR), name="tiles") -``` -- `bayern.pmtiles` nach `/volume1/docker/banyaro-staging/data/tiles/` kopieren - (scp zur DS), dann `make staging`. -- Starlette `FileResponse` beherrscht Range-Requests → MapLibre/pmtiles bekommt 206. -- **Verifizieren:** `curl -I -H "Range: bytes=0-1023" https://staging.banyaro.app/tiles/bayern.pmtiles` - muss **HTTP/1.1 206 Partial Content** + `Accept-Ranges: bytes` liefern. Prüfen, dass - NPM die Range-Header nicht verschluckt. - -### 4.3 MapLibre-Testseite (Feature-Flag, Leaflet bleibt Fallback) -- `maplibre-gl` + `pmtiles` einbinden, Protokoll registrieren: - ```js - const p = new pmtiles.Protocol(); - maplibregl.addProtocol('pmtiles', p.tile); - // source: { type:'vector', url:'pmtiles://https://staging.banyaro.app/tiles/bayern.pmtiles' } - ``` -- Style-JSON (OpenMapTiles-Schema) + Glyphs + Sprite hosten (z. B. unter `/tiles/style/`). - Fertige freie Styles: OSM-Bright / Positron (maputnik-kompatibel). Glyphs z. B. aus - `openmaptiles/fonts`. Für den Spike darf der Style minimal sein. -- Touren-Polyline als GeoJSON-`line`-Layer auf die Map legen (ist basemap-unabhängig — - sobald MapLibre läuft, ist die Tour nur noch ein Layer). - -### 4.4 Messen & festhalten -- Dateigröße + Generierungszeit (Bayern, dann hochrechnen auf DACH). -- Render-Performance über den Heim-Uplink (erste Zoomstufen, Labels). -- DS-Storage-Budget (`/volume1/...` frei?). -- Range-Requests durch NPM ok? CORS nötig (falls Tiles auf anderer Subdomain als App)? - ---- - -## 5. Offene Entscheidungen (mit René klären) - -1. **Region-Scope:** DACH am Stück vs. Deutschland-only vs. **herunterladbare - Regionen** (für iOS-Offline granular). DACH-PMTiles grob ~2–3 GB (verifizieren). -2. **Hosting-Pfad:** Pfad (`/tiles` an der App) vs. eigene Subdomain - (`tiles.banyaro.app`) hinter NPM. Subdomain = sauberer cachebar, aber CORS-Setup. - Für Skalierung ggf. nginx direkt statt FastAPI StaticFiles (kein App-CPU). -3. **Style:** welcher Basis-Style (Positron/OSM-Bright/eigenes Hunde-Theme), Glyphs/Sprite - selbst hosten. -4. **Update-Kadenz:** wie oft aus frischem OSM-Extract neu generieren (monatlich?). - planetiler-Rerun + Datei tauschen (atomar). Cron/Make-Target dafür. -5. **iOS-Workstream:** MapLibre Native + Offline-Region-Download ersetzt MapKit — - eigener Build-4-Task im `banyaro-ios`-Repo, **nach** dieser Infra. -6. **Attribution (Pflicht):** „© OpenStreetMap contributors" (ODbL) muss in Web **und** - iOS sichtbar sein. Bei eigenem Style ggf. zusätzliche Datenquellen-Hinweise. - ---- - -## 6. Vorgeschlagene Reihenfolge - -1. Staging-Spike (Abschnitt 4) mit **Bayern** — Proof of Concept, Zahlen sammeln. -2. Entscheidungen aus Abschnitt 5 mit René. -3. DACH-PMTiles generieren (osmium merge → planetiler), Update-Make-Target. -4. PWA: MapLibre hinter Feature-Flag produktiv, Leaflet-Pfad rausnehmen wenn stabil. -5. iOS (separat): MapLibre Native + Offline-Regionen, MapKit ablösen. - ---- - -## 7. Querverweise - -- iOS-Memory: `project_build4_karte` (Entscheidung OSM/MapLibre/Offline), - `project_build4_pois` (POI-Pipeline aus pbf — gleiche Datenquelle), - `project_osm_contribution` (OSM-Beitragskreislauf, gehört in die PWA), - `project_companion` (Companion-Prinzip), `project_app_review_build3` (warum nicht im Resubmit). -- NPM/IPv6/Reverse-Proxy-Stolpersteine auf der DS: Skill `synology-troubleshooting`. -- Deploy: `make staging` (→ staging.banyaro.app), `make deploy` (→ Produktion, deployt - den **Arbeitsbaum**). `make bump` NUR bei Frontend-Asset-Änderungen (SW-Cache). diff --git a/tests/test_account_deletion.py b/tests/test_account_deletion.py deleted file mode 100644 index e016966..0000000 --- a/tests/test_account_deletion.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -Account-Löschung (DSGVO + App-Store-Gl. 4): muss FK-sicher ALLE Daten entfernen, -auch wenn der User Zeilen in Tabellen ohne ON DELETE CASCADE hat (routes, places, -walks, events, forum_threads, …). Regressionstest gegen den alten, FK-unvollständigen -Delete, der am finalen `DELETE FROM users` scheiterte, sobald solche Zeilen existierten. -""" -import secrets - - -def _make_user(client): - from database import db - email = f"del-{secrets.token_hex(4)}@example.com" - pw, name = "TestPass123!", f"deltest{secrets.token_hex(3)}" - r = client.post("/api/auth/register", json={"email": email, "password": pw, "name": name}) - assert r.status_code == 200, r.text - with db() as conn: - conn.execute("UPDATE users SET email_verified=1 WHERE email=?", (email,)) - uid = conn.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone()["id"] - token = client.post("/api/auth/login", json={"email": email, "password": pw}).json()["token"] - return uid, {"Authorization": f"Bearer {token}"} - - -def test_delete_account_with_noncascade_data(client): - from database import db - uid, headers = _make_user(client) - dog_id = client.post("/api/dogs", headers=headers, - json={"name": "Rex", "rasse": "Mix", "is_public": False}).json()["id"] - - # Direkt Zeilen in den Tabellen anlegen, die users(id) OHNE Cascade referenzieren — - # genau die, die den alten Delete blockiert haben. - with db() as conn: - conn.execute("INSERT INTO routes (user_id, name, gps_track) VALUES (?,?,?)", - (uid, "Testrunde", "[]")) - conn.execute("INSERT INTO places (user_id, name, typ, lat, lon) VALUES (?,?,?,?,?)", - (uid, "Hundewiese", "freilauf", 52.5, 13.4)) - conn.execute("INSERT INTO walks (user_id, titel, datum, uhrzeit, lat, lon) VALUES (?,?,?,?,?,?)", - (uid, "Gassi-Treff", "2026-07-01", "18:00", 52.5, 13.4)) - conn.execute("INSERT INTO events (user_id, titel, datum) VALUES (?,?,?)", - (uid, "Hundewanderung", "2026-07-02")) - conn.execute("INSERT INTO forum_threads (user_id, titel) VALUES (?,?)", - (uid, "Hallo Forum")) - - resp = client.delete("/api/profile/account", headers=headers) - assert resp.status_code == 200, f"Delete failed: {resp.status_code} {resp.text}" - assert resp.json()["status"] == "deleted" - - with db() as conn: - assert conn.execute("SELECT 1 FROM users WHERE id=?", (uid,)).fetchone() is None - for tbl in ("routes", "places", "walks", "events", "forum_threads", "dogs"): - cnt = conn.execute(f"SELECT COUNT(*) c FROM {tbl} WHERE user_id=?", (uid,)).fetchone()["c"] - assert cnt == 0, f"{tbl} hat noch {cnt} Zeile(n) nach Account-Löschung" - - -def test_delete_account_minimal_user(client): - """Auch ein User ganz ohne Zusatzdaten lässt sich löschen.""" - from database import db - uid, headers = _make_user(client) - resp = client.delete("/api/profile/account", headers=headers) - assert resp.status_code == 200, resp.text - with db() as conn: - assert conn.execute("SELECT 1 FROM users WHERE id=?", (uid,)).fetchone() is None