From bb20135876defa368afb0389a1c7bd08f0138b5a Mon Sep 17 00:00:00 2001 From: guyg1474 <340572510+guyg1474@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:48:19 +0200 Subject: [PATCH] =?UTF-8?q?Datenschutz:=20Bild-Metadaten,=20private=20Vors?= =?UTF-8?q?chl=C3=A4ge,=20fremde=20Bilder,=20API-Doku?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Uploads: Server entfernt EXIF/XMP/Text (GPS-Standort, Kamera, Uhrzeit) per Pillow; Drehung von Hochkant-Fotos wird vorher angewendet, Farbprofil bleibt; GIFs unverändert (neue Abhängigkeit pillow) - Produktvorschläge pro Liste (Tabelle list_products): Einträge privater Listen tauchen bei anderen weder in Vorschlägen noch beim Rezeptabgleich auf; "Vorschläge verwalten" löscht nur aus eigenen sichtbaren Listen - Artikel: Bilder nur noch aus den eigenen Uploads (kein Tracking-Pixel fremder Server); Bereinigung in htmlclean.py - /api/docs und /api/openapi.json nur noch im Entwicklungsmodus (RELOAD=1 oder P42_API_DOCS=1) - Migration Schritt 2: bestehende Vorschläge den Listen zuordnen, Artikel neu bereinigen, gespeicherte Bilder mit Metadaten säubern - Tests in test_datenschutz.py (u. a. Rezept-Zuordnung, Umstellung) Co-Authored-By: Claude Opus 5.5 --- README.md | 16 +++- backend/app/bilder.py | 54 +++++++++++ backend/app/db.py | 11 ++- backend/app/haushalt.py | 52 ++++++++--- backend/app/htmlclean.py | 32 +++++++ backend/app/main.py | 10 ++- backend/app/migrations.py | 46 +++++++++- backend/app/projects.py | 33 +------ backend/app/rezept.py | 28 +++--- backend/requirements.txt | 1 + backend/tests/test_datenschutz.py | 145 ++++++++++++++++++++++++++++++ 11 files changed, 367 insertions(+), 61 deletions(-) create mode 100644 backend/app/bilder.py create mode 100644 backend/app/htmlclean.py create mode 100644 backend/tests/test_datenschutz.py diff --git a/README.md b/README.md index d3c708b..e569283 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,8 @@ python3 -m pip --python .venv/bin/python install -r requirements-dev.txt # ein Jeder Test bekommt eine frische, leere Datenbank. `tests/test_migrationen.py` baut ältere Datenbank-Stände nach und prüft, dass der Start sie verlustfrei umstellt. -Die API-Dokumentation gibt es unter http://localhost:3000/api/docs. +Die API-Dokumentation gibt es nur im Entwicklungsmodus (`RELOAD=1` oder `P42_API_DOCS=1`) unter +http://localhost:3000/api/docs – im Betrieb ist sie abgeschaltet. ## Konfiguration (`config.json`) @@ -137,7 +138,11 @@ wird der Artikel direkt auf der Seite geändert. Bilder lassen sich per Button, Einfügen (Strg+V) oder Drag & Drop in den Text setzen. Nicht mehr verwendete Bilder werden automatisch gelöscht. Der Artikeltext wird beim -Speichern serverseitig bereinigt. +Speichern serverseitig bereinigt; Bilder sind nur aus den eigenen Uploads erlaubt (fremde Bilder +würden beim Lesen IP-Adresse und Zeitpunkt an deren Server verraten). + +Beim Hochladen entfernt der Server alle Metadaten aus Fotos (EXIF/XMP: GPS-Standort, Kamera, +Uhrzeit); die Drehung von Hochkant-Fotos bleibt erhalten. GIFs bleiben unverändert. ### Vorrat & Einkauf @@ -151,6 +156,9 @@ und einen eigenen Einkaufszettel. Administratoren aber nur bei Listen, die sie sehen. Löschen entfernt Vorrat und Einkauf der Liste. - Beim Update werden der bisherige Vorrat und die bisherige Einkaufsliste zur Liste „Zuhause“ (für alle sichtbar). +- **Vorschläge** beim Eintippen und die Zuordnung beim Rezeptabgleich kommen nur aus Listen, die man + sehen darf – Einträge privater Listen tauchen bei anderen nicht auf. „Vorschläge verwalten“ löscht + nur aus den eigenen sichtbaren Listen. - Welche Liste gewählt ist, merkt sich der Browser. Gekauft, aufgebraucht, Nachkaufen und der Rezeptabgleich wirken immer innerhalb der gewählten Liste. - **Einkaufen:** Abhaken legt ein Produkt „in den Wagen“ – es rutscht nach unten, bleibt aber auf der @@ -237,7 +245,9 @@ backend/ app/db.py SQLite-Schema (neuester Stand), Verbindungen, Übernahme alter JSON-Daten app/migrations.py Nummerierte Umstellungen bestehender Datenbanken (Regel steht oben in der Datei) app/auth.py Konten, Anmeldung, Benutzerverwaltung - app/projects.py Projekte/Artikel, Bild-Uploads, HTML-Bereinigung + app/projects.py Projekte/Artikel, Bild-Uploads + app/htmlclean.py Artikel-HTML bereinigen (nur eigene Bilder) + app/bilder.py Metadaten (GPS usw.) aus hochgeladenen Bildern entfernen app/links.py Schnellzugriff-Links pro Konto app/monitoring.py Monitoring pro Konto (HTTP/TCP-Prüfung, P42-S.M.-Auslastung + Verlauf) app/haushalt.py Listen (je Vorrat + Einkauf) mit Sichtbarkeit, Produktkatalog, Einheiten diff --git a/backend/app/bilder.py b/backend/app/bilder.py new file mode 100644 index 0000000..ae5b8ab --- /dev/null +++ b/backend/app/bilder.py @@ -0,0 +1,54 @@ +"""Hochgeladene Bilder ohne Metadaten speichern. + +Fotos vom Handy enthalten in den EXIF-Daten oft den Aufnahmeort (GPS), das Gerät und die Uhrzeit. +Die Bilder sind für alle Konten sichtbar, deshalb wird alles außer den Bildpunkten entfernt. +Die Drehung aus den EXIF-Daten wird vorher angewendet, damit Hochkant-Fotos richtig stehen bleiben. +Nur das Farbprofil (ICC) bleibt erhalten, damit sich die Farben nicht verändern. + +GIFs bleiben unverändert: Sie haben keine EXIF-/GPS-Daten, und neu speichern würde Animationen verschlechtern. +""" +import io + +from PIL import Image, ImageOps + +from .common import ValidationError + +# Schutz vor "Dekompressionsbomben" (winzige Datei, riesiges Bild) +Image.MAX_IMAGE_PIXELS = 60_000_000 + + +def hat_metadaten(data: bytes) -> bool: + """Enthält das Bild EXIF-, XMP- oder Text-Angaben (z. B. GPS, Kamera, Kommentar)?""" + try: + with Image.open(io.BytesIO(data)) as img: + return bool(img.getexif()) or any(k in img.info for k in ("xmp", "XML:com.adobe.xmp", "comment", "exif")) \ + or bool(getattr(img, "text", None)) + except (OSError, ValueError, Image.DecompressionBombError): + return False + + +def ohne_metadaten(data: bytes, ext: str) -> bytes: + """Bild neu schreiben, ohne EXIF/XMP/Text-Angaben. ext: jpg, png, webp oder gif.""" + if ext == "gif": + return data + try: + with Image.open(io.BytesIO(data)) as img: + animated = getattr(img, "is_animated", False) + icc = img.info.get("icc_profile") + out = io.BytesIO() + if ext == "webp" and animated: + # Animierte WebP: alle Bilder übernehmen, Metadaten fallen weg + img.save(out, "WEBP", save_all=True, quality=90, icc_profile=icc, duration=img.info.get("duration"), loop=img.info.get("loop", 0)) + return out.getvalue() + img = ImageOps.exif_transpose(img) + if ext == "jpg": + if img.mode not in ("RGB", "L", "CMYK"): + img = img.convert("RGB") + img.save(out, "JPEG", quality=92, optimize=True, icc_profile=icc) + elif ext == "png": + img.save(out, "PNG", optimize=True, icc_profile=icc) + else: + img.save(out, "WEBP", quality=90, icc_profile=icc) + return out.getvalue() + except (OSError, ValueError, Image.DecompressionBombError): + raise ValidationError("Das Bild konnte nicht gelesen werden oder ist zu groß.") diff --git a/backend/app/db.py b/backend/app/db.py index e7e6ff5..600e636 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -138,7 +138,7 @@ CREATE TABLE IF NOT EXISTS shopping_list_members ( CREATE TABLE IF NOT EXISTS household_items {HOUSEHOLD_TABLE}; --- Produktkatalog für die Vorschläge +-- Produktkatalog: einheitliche Schreibweise ("milch" → "Milch") CREATE TABLE IF NOT EXISTS products ( key TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -146,6 +146,15 @@ CREATE TABLE IF NOT EXISTS products ( last_used TEXT NOT NULL ); +-- Welche Produkte in welcher Liste vorkamen: Vorschläge sieht man nur aus Listen, die man sehen darf +CREATE TABLE IF NOT EXISTS list_products ( + list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, + key TEXT NOT NULL, + uses INTEGER NOT NULL DEFAULT 0, + last_used TEXT NOT NULL, + PRIMARY KEY (list_id, key) +); + -- Gelernte Zuordnungen Rezeptzutat → Produkt (z. B. "margarine" → "Butter") CREATE TABLE IF NOT EXISTS ingredient_aliases ( key TEXT PRIMARY KEY, diff --git a/backend/app/haushalt.py b/backend/app/haushalt.py index 938d2c1..0d8f756 100644 --- a/backend/app/haushalt.py +++ b/backend/app/haushalt.py @@ -82,20 +82,39 @@ def _sort_key(name: str) -> str: return _key(name).translate(_UMLAUTS) -def _canonical_name(conn, name: str) -> str: - """Schreibweise aus dem Katalog übernehmen bzw. neues Produkt aufnehmen.""" +def _canonical_name(conn, name: str, list_id=None) -> str: + """Schreibweise aus dem Katalog übernehmen bzw. neues Produkt aufnehmen; Nutzung in der Liste mitzählen.""" row = conn.execute("SELECT name FROM products WHERE key = ?", (_key(name),)).fetchone() if row: conn.execute("UPDATE products SET uses = uses + 1, last_used = ? WHERE key = ?", (now_iso(), _key(name))) - return row["name"] - if conn.execute("SELECT COUNT(*) FROM products").fetchone()[0] >= MAX_PRODUCTS: - raise ValidationError("Der Produktkatalog ist voll. Bitte alte Vorschläge löschen.") - # Neue Produkte mit großem Anfangsbuchstaben, damit "milch" und "Milch" nicht doppelt entstehen - name = name[0].upper() + name[1:] - conn.execute("INSERT INTO products (key, name, uses, last_used) VALUES (?, ?, 1, ?)", (_key(name), name, now_iso())) + name = row["name"] + else: + if conn.execute("SELECT COUNT(*) FROM products").fetchone()[0] >= MAX_PRODUCTS: + raise ValidationError("Der Produktkatalog ist voll. Bitte alte Vorschläge löschen.") + # Neue Produkte mit großem Anfangsbuchstaben, damit "milch" und "Milch" nicht doppelt entstehen + name = name[0].upper() + name[1:] + conn.execute("INSERT INTO products (key, name, uses, last_used) VALUES (?, ?, 1, ?)", (_key(name), name, now_iso())) + if list_id is not None: + conn.execute( + """INSERT INTO list_products (list_id, key, uses, last_used) VALUES (?, ?, 1, ?) + ON CONFLICT (list_id, key) DO UPDATE SET uses = uses + 1, last_used = excluded.last_used""", + (list_id, _key(name), now_iso()), + ) return name +def visible_products(conn, user: dict) -> list: + """Produkte aus den Listen, die das Konto sehen darf – häufig genutzte zuerst.""" + rows = conn.execute( + f"""SELECT p.name, SUM(lp.uses) AS uses FROM list_products lp + JOIN products p ON p.key = lp.key + JOIN shopping_lists l ON l.id = lp.list_id + WHERE {_VISIBLE} GROUP BY p.key""", + _who(user), + ).fetchall() + return [r["name"] for r in sorted(rows, key=lambda r: (-r["uses"], _sort_key(r["name"])))] + + def _add(conn, name: str, list_: str, list_id: int, quantity, unit: str, user_id: int) -> None: """Produkt in den Vorrat bzw. Einkauf einer Liste setzen; steht es dort schon in derselben Einheit, wird die Menge erhöht. @@ -199,13 +218,11 @@ def _state(conn, user: dict) -> dict: WHERE i.list_id IN ({','.join('?' * len(ids))})""", ids, ).fetchall() - # Häufig genutzte Produkte zuerst vorschlagen - products = conn.execute("SELECT name, uses FROM products").fetchall() return { "items": [_item(r) for r in sorted(items, key=lambda r: _sort_key(r["name"]))], "lists": [_shopping_list(conn, r, user) for r in lists], "users": user_list(conn), - "products": [r["name"] for r in sorted(products, key=lambda r: (-r["uses"], _sort_key(r["name"])))], + "products": visible_products(conn, user), } @@ -225,7 +242,7 @@ def add_item(data: dict = Body(...), user: dict = Depends(current_user)): quantity = _quantity(data.get("quantity", 1), unit) with transaction() as conn: list_id = _get_list(conn, data.get("listId"), user)["id"] - _add(conn, _canonical_name(conn, name), list_, list_id, quantity, unit, user["id"]) + _add(conn, _canonical_name(conn, name, list_id), list_, list_id, quantity, unit, user["id"]) return _state(conn, user) @@ -343,9 +360,16 @@ def delete_list(list_id: int, user: dict = Depends(current_user)): @router.delete("/api/haushalt/produkte") def delete_product(name: str, user: dict = Depends(current_user)): - """Vorschlag aus dem Katalog entfernen (z. B. Tippfehler). Einträge auf den Listen bleiben.""" + """Vorschlag entfernen (z. B. Tippfehler) – nur aus den Listen, die das Konto sieht. Einträge auf den Listen bleiben.""" with transaction() as conn: - cur = conn.execute("DELETE FROM products WHERE key = ?", (_key(name),)) + ids = [r["id"] for r in _visible_lists(conn, user)] + cur = conn.execute( + f"DELETE FROM list_products WHERE key = ? AND list_id IN ({','.join('?' * len(ids))})", (_key(name), *ids) + ) if not cur.rowcount: raise HTTPException(404, "Vorschlag nicht gefunden.") + # Schreibweise nur löschen, wenn keine Liste das Produkt mehr kennt + conn.execute( + "DELETE FROM products WHERE key = ? AND NOT EXISTS (SELECT 1 FROM list_products WHERE key = ?)", (_key(name), _key(name)) + ) return _state(conn, user) diff --git a/backend/app/htmlclean.py b/backend/app/htmlclean.py new file mode 100644 index 0000000..2417fc2 --- /dev/null +++ b/backend/app/htmlclean.py @@ -0,0 +1,32 @@ +"""Artikel-HTML bereinigen: nur einfache Formatierung, Links und Bilder aus den eigenen Uploads. + +Bilder von fremden Servern sind nicht erlaubt: Beim Lesen würde der Browser sie dort abrufen und so +IP-Adresse und Lesezeitpunkt verraten (Tracking-Pixel). Externe Links bleiben erlaubt (erst ein Klick ruft sie auf). +""" +import re + +import nh3 + +ALLOWED_TAGS = {"p", "br", "h2", "h3", "strong", "b", "em", "i", "u", "s", "ul", "ol", "li", "blockquote", "hr", "a", "img"} +ALLOWED_ATTRS = {"a": {"href"}, "img": {"src", "alt"}} +UPLOAD_SRC = re.compile(r"/uploads/[a-f0-9]{24}\.(?:jpg|png|gif|webp)") + + +def _attribute_filter(tag: str, attr: str, value: str) -> str | None: + if tag == "img" and attr == "src" and not UPLOAD_SRC.fullmatch(value): + return None + return value + + +def sanitize(content: str) -> str: + cleaned = nh3.clean( + content, + tags=ALLOWED_TAGS, + clean_content_tags={"script", "style"}, + attributes=ALLOWED_ATTRS, + attribute_filter=_attribute_filter, + url_schemes={"http", "https", "mailto"}, + link_rel="noopener noreferrer", + ) + # Bilder, deren Quelle entfernt wurde, ganz weglassen + return re.sub(r"]*\ssrc=)[^>]*>", "", cleaned).strip() diff --git a/backend/app/main.py b/backend/app/main.py index 48eeada..c0731ca 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,6 +4,7 @@ Liefert die API unter /api, die hochgeladenen Bilder unter /uploads und (falls gebaut) das React-Frontend aus frontend/dist. """ import asyncio +import os import re from contextlib import asynccontextmanager @@ -33,7 +34,14 @@ async def lifespan(_: FastAPI): capitals.cancel() -app = FastAPI(title="P42 Dashboard", docs_url="/api/docs", openapi_url="/api/openapi.json", lifespan=lifespan) +# API-Beschreibung nur bei der Entwicklung (RELOAD=1 oder P42_API_DOCS=1) – öffentlich verriete sie alle Schnittstellen +API_DOCS = os.environ.get("RELOAD") == "1" or os.environ.get("P42_API_DOCS") == "1" +app = FastAPI( + title="P42 Dashboard", + docs_url="/api/docs" if API_DOCS else None, + openapi_url="/api/openapi.json" if API_DOCS else None, + lifespan=lifespan, +) # Komprimiert u. a. die JS-Bundles und die Weltkarte (~740 KB → ~200 KB) app.add_middleware(GZipMiddleware, minimum_size=1000) diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 880e651..fca704c 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -6,8 +6,16 @@ SCHEMA in db.py beschreibt immer den neuesten Stand. Bei einer Änderung am Sche Jeder Schritt läuft genau einmal, in einer Transaktion mit dem Hochzählen der Nummer. Tests: tests/test_migrationen.py (alte Stände nachbauen und umstellen). """ -from .common import now_iso +import logging +import re + +from .bilder import hat_metadaten, ohne_metadaten +from .common import ValidationError, now_iso +from .config import DATA_DIR from .db import HOUSEHOLD_TABLE, KANBAN_PROJECTS_TABLE, KANBAN_TASKS_TABLE +from .htmlclean import sanitize + +log = logging.getLogger(__name__) def start(conn) -> None: @@ -176,7 +184,43 @@ def _kanban_projects(conn) -> None: conn.execute("DROP TABLE kanban_tasks_alt") +# ---------- Schritt 2: Datenschutz ---------- + + +def _v2_datenschutz(conn) -> None: + """Vorschläge pro Liste, Artikel ohne fremde Bilder, hochgeladene Bilder ohne Metadaten (GPS usw.).""" + # Vorschläge den Listen zuordnen: was gerade auf einer Liste steht, gehört zu dieser Liste; + # alles Übrige stammt aus der Zeit vor den privaten Listen und gehört zu den Listen, die alle sehen + conn.execute( + """INSERT OR IGNORE INTO list_products (list_id, key, uses, last_used) + SELECT DISTINCT i.list_id, p.key, p.uses, p.last_used FROM household_items i JOIN products p ON p.key = i.key""" + ) + conn.execute( + """INSERT OR IGNORE INTO list_products (list_id, key, uses, last_used) + SELECT l.id, p.key, p.uses, p.last_used FROM products p JOIN shopping_lists l ON l.everyone = 1 + WHERE NOT EXISTS (SELECT 1 FROM household_items i WHERE i.key = p.key)""" + ) + # Artikel mit den neuen Regeln bereinigen (Bilder nur noch aus den eigenen Uploads) + for row in conn.execute("SELECT id, content FROM projects").fetchall(): + cleaned = sanitize(row["content"] or "") + if cleaned != (row["content"] or ""): + conn.execute("UPDATE projects SET content = ? WHERE id = ?", (cleaned, row["id"])) + # Hochgeladene Bilder: nur solche mit Metadaten neu schreiben (die anderen bleiben unverändert) + uploads = DATA_DIR / "uploads" + if uploads.is_dir(): + for f in uploads.iterdir(): + if not re.fullmatch(r"[a-f0-9]{24}\.(jpg|png|webp)", f.name): + continue + data = f.read_bytes() + if hat_metadaten(data): + try: + f.write_bytes(ohne_metadaten(data, f.suffix[1:])) + except ValidationError: + log.warning("Bild %s konnte nicht bereinigt werden", f.name) + + # Reihenfolge = Versionsnummer (Index + 1). Nur anhängen, nie umsortieren oder entfernen. STEPS = [ _v1_altbestand, + _v2_datenschutz, ] diff --git a/backend/app/projects.py b/backend/app/projects.py index cc96ae0..f3ecbf0 100644 --- a/backend/app/projects.py +++ b/backend/app/projects.py @@ -3,14 +3,15 @@ import re import secrets import time -import nh3 from fastapi import APIRouter, Body, Depends, HTTPException, Request from fastapi.responses import FileResponse from .auth import current_user +from .bilder import ohne_metadaten from .config import DATA_DIR from .common import ValidationError, clean_text, now_iso from .db import transaction +from .htmlclean import sanitize from .kanban import boards_for_article router = APIRouter() @@ -26,33 +27,6 @@ MAX_UPLOAD = 15 * 1024 * 1024 MIME = {"jpg": "image/jpeg", "png": "image/png", "gif": "image/gif", "webp": "image/webp"} -# ---------- Artikel-HTML bereinigen ---------- - -ALLOWED_TAGS = {"p", "br", "h2", "h3", "strong", "b", "em", "i", "u", "s", "ul", "ol", "li", "blockquote", "hr", "a", "img"} -ALLOWED_ATTRS = {"a": {"href"}, "img": {"src", "alt"}} - - -def _attribute_filter(tag: str, attr: str, value: str) -> str | None: - # Bilder nur aus den eigenen Uploads oder per https - if tag == "img" and attr == "src" and not (value.startswith("/uploads/") or value.startswith("https://")): - return None - return value - - -def sanitize(content: str) -> str: - cleaned = nh3.clean( - content, - tags=ALLOWED_TAGS, - clean_content_tags={"script", "style"}, - attributes=ALLOWED_ATTRS, - attribute_filter=_attribute_filter, - url_schemes={"http", "https", "mailto"}, - link_rel="noopener noreferrer", - ) - # Bilder, deren Quelle entfernt wurde, ganz weglassen - return re.sub(r"]*\ssrc=)[^>]*>", "", cleaned).strip() - - # ---------- Validierung ---------- def _text(value, field: str, max_len: int, required: bool = False) -> str: @@ -247,7 +221,8 @@ async def upload(request: Request, _: dict = Depends(current_user)): if not ext: raise ValidationError("Bildformat nicht unterstützt (erlaubt: JPG, PNG, GIF, WebP).") name = f"{secrets.token_hex(12)}.{ext}" - (UPLOAD_DIR / name).write_bytes(data) + # GPS-Standort, Gerät usw. entfernen: die Bilder sehen alle Konten + (UPLOAD_DIR / name).write_bytes(ohne_metadaten(bytes(data), ext)) return {"file": name} diff --git a/backend/app/rezept.py b/backend/app/rezept.py index 9cd5dcf..e336271 100644 --- a/backend/app/rezept.py +++ b/backend/app/rezept.py @@ -16,7 +16,7 @@ from fastapi import APIRouter, Body, Depends from .auth import current_user from .db import transaction -from .haushalt import _add, _canonical_name, _clean_name, _get_list, _key, _quantity, _state, _unit +from .haushalt import _add, _canonical_name, _clean_name, _get_list, _key, _quantity, _state, _unit, visible_products from .common import ValidationError router = APIRouter() @@ -245,10 +245,13 @@ def _stem(name: str) -> str: return s -def _matcher(conn): - aliases = {r["key"]: r["product"] for r in conn.execute("SELECT key, product FROM ingredient_aliases")} - products = [r["name"] for r in conn.execute("SELECT name FROM products")] +def _matcher(conn, user: dict): + # Nur Produkte aus Listen, die das Konto sehen darf – sonst verriete die Zuordnung fremde private Einträge + products = visible_products(conn, user) exact = {_key(p): p for p in products} + aliases = { + r["key"]: r["product"] for r in conn.execute("SELECT key, product FROM ingredient_aliases") if _key(r["product"]) in exact + } stems = {} for p in products: stems.setdefault(_stem(p), p) @@ -278,11 +281,11 @@ def _matcher(conn): return match -def _result(conn, title, source, servings, raw_ingredients) -> dict: +def _result(conn, user, title, source, servings, raw_ingredients) -> dict: parsed = [p for p in (parse_ingredient(r) for r in raw_ingredients[:MAX_INGREDIENTS]) if p] if not parsed: raise ValidationError("Keine Zutaten gefunden.") - match = _matcher(conn) + match = _matcher(conn, user) return { "title": title, "source": source, @@ -295,7 +298,7 @@ def _result(conn, title, source, servings, raw_ingredients) -> dict: @router.post("/api/haushalt/rezept") -async def load_recipe(data: dict = Body(...), _: dict = Depends(current_user)): +async def load_recipe(data: dict = Body(...), user: dict = Depends(current_user)): """Zutaten aus einem Rezept-Link ({url}) oder eingefügtem Text ({text, servings}) lesen.""" url = data.get("url").strip() if isinstance(data.get("url"), str) else "" text = data.get("text") if isinstance(data.get("text"), str) else "" @@ -312,7 +315,7 @@ async def load_recipe(data: dict = Body(...), _: dict = Depends(current_user)): ingredients = ingredients.splitlines() title = html.unescape(str(recipe.get("name") or "Rezept"))[:200] with transaction() as conn: - return _result(conn, title, url, _servings(recipe.get("recipeYield")), ingredients) + return _result(conn, user, title, url, _servings(recipe.get("recipeYield")), ingredients) if text.strip(): if len(text) > MAX_TEXT: raise ValidationError("Der Text ist zu lang.") @@ -320,7 +323,7 @@ async def load_recipe(data: dict = Body(...), _: dict = Depends(current_user)): servings = servings if isinstance(servings, int) and 0 < servings <= 100 else None lines = [line.strip(" \t-•*·") for line in text.splitlines()] with transaction() as conn: - return _result(conn, "Eigene Zutatenliste", None, servings, [line for line in lines if line]) + return _result(conn, user, "Eigene Zutatenliste", None, servings, [line for line in lines if line]) raise ValidationError("Bitte einen Rezept-Link oder eine Zutatenliste angeben.") @@ -342,9 +345,10 @@ def take_over(data: dict = Body(...), user: dict = Depends(current_user)): checked.append((_clean_name(i.get("name")), _quantity(i.get("quantity"), unit), unit)) learned = [(_clean_name(a.get("ingredient")), _clean_name(a.get("product"))) for a in aliases if isinstance(a, dict)] with transaction() as conn: - list_id = _get_list(conn, data.get("listId"), user)["id"] if checked else None # Einkauf dieser Liste + # Einkauf dieser Liste; gelernte Zuordnungen zählen als Vorschläge dieser Liste + list_id = _get_list(conn, data.get("listId"), user)["id"] if checked or data.get("listId") is not None else None for ingredient, product in learned: - product = _canonical_name(conn, product) + product = _canonical_name(conn, product, list_id) if _key(ingredient) == _key(product): conn.execute("DELETE FROM ingredient_aliases WHERE key = ?", (_key(ingredient),)) else: @@ -354,5 +358,5 @@ def take_over(data: dict = Body(...), user: dict = Depends(current_user)): (_key(ingredient), product), ) for name, quantity, unit in checked: - _add(conn, _canonical_name(conn, name), "einkauf", list_id, quantity, unit, user["id"]) + _add(conn, _canonical_name(conn, name, list_id), "einkauf", list_id, quantity, unit, user["id"]) return _state(conn, user) diff --git a/backend/requirements.txt b/backend/requirements.txt index 7dc3c77..c4bbd08 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,3 +2,4 @@ fastapi==0.143.0 uvicorn[standard]==0.54.0 httpx==0.28.1 nh3==0.3.7 +pillow==12.3.0 diff --git a/backend/tests/test_datenschutz.py b/backend/tests/test_datenschutz.py new file mode 100644 index 0000000..f2347cb --- /dev/null +++ b/backend/tests/test_datenschutz.py @@ -0,0 +1,145 @@ +"""Datenschutz: keine Metadaten in Bildern, private Listen bleiben privat, keine fremden Bilder, keine offene API-Doku.""" +import io + +from fastapi.testclient import TestClient +from PIL import Image, PngImagePlugin + +from app import db +from app.config import DATA_DIR +from app.main import app + +GPS = {1: "N", 2: (52.0, 31.0, 12.0), 3: "E", 4: (13.0, 24.0, 36.0)} + + +def jpeg_mit_gps(width=60, height=30, orientation=None) -> bytes: + exif = Image.Exif() + exif[0x8825] = GPS + exif[0x010F] = "Testkamera" + if orientation: + exif[0x0112] = orientation + buf = io.BytesIO() + Image.new("RGB", (width, height), "red").save(buf, "JPEG", exif=exif.tobytes()) + return buf.getvalue() + + +def upload(client, data: bytes) -> str: + res = client.post("/api/uploads", content=data) + assert res.status_code == 201, res.text + return res.json()["file"] + + +# ---------- Bilder ---------- + + +def test_upload_ohne_gps_und_kamera(login): + anna, ben = login("anna"), login("ben") + name = upload(anna, jpeg_mit_gps()) + img = Image.open(io.BytesIO(ben.get(f"/uploads/{name}").content)) + assert not img.getexif() + assert img.size == (60, 30) + + +def test_hochkant_foto_bleibt_hochkant(login): + # Orientierung 6 = 90° gedreht: ohne Anwenden läge das Foto nach dem Entfernen der EXIF-Daten quer + name = upload(login("anna"), jpeg_mit_gps(60, 30, orientation=6)) + assert Image.open(DATA_DIR / "uploads" / name).size == (30, 60) + + +def test_png_ohne_textangaben(login): + info = PngImagePlugin.PngInfo() + info.add_text("Comment", "Aufgenommen in der Musterstraße 1") + buf = io.BytesIO() + Image.new("RGB", (10, 10)).save(buf, "PNG", pnginfo=info) + name = upload(login("anna"), buf.getvalue()) + assert not Image.open(DATA_DIR / "uploads" / name).text + + +def test_kaputtes_bild_abgelehnt(login): + res = login("anna").post("/api/uploads", content=b"\xff\xd8\xff" + b"kein bild" * 10) + assert res.status_code == 400 + + +# ---------- Private Listen ---------- + + +def private_liste(anna): + lid = anna.post("/api/haushalt/listen", json={"name": "Privat", "everyone": False}).json()["created"] + anna.post("/api/haushalt/items", json={"list": "einkauf", "name": "Schwangerschaftstest", "listId": lid}) + return lid + + +def test_privates_produkt_nicht_in_fremden_vorschlaegen(login): + anna, ben = login("anna"), login("ben") + lid = private_liste(anna) + assert "Schwangerschaftstest" in anna.get("/api/haushalt").json()["products"] + assert "Schwangerschaftstest" not in ben.get("/api/haushalt").json()["products"] + # Nach Freigabe sieht Ben es + anna.patch(f"/api/haushalt/listen/{lid}", json={"name": "Privat", "everyone": False, "members": [ben.user["id"]]}) + assert "Schwangerschaftstest" in ben.get("/api/haushalt").json()["products"] + + +def test_rezept_zuordnung_verraet_nichts(login): + anna, ben = login("anna"), login("ben") + lid = anna.post("/api/haushalt/listen", json={"name": "Privat", "everyone": False}).json()["created"] + anna.post("/api/haushalt/items", json={"list": "einkauf", "name": "Kondome", "listId": lid}) + # Ähnliche Schreibweise würde sonst Annas privaten Eintrag als "bekanntes Produkt" zuordnen + ing = ben.post("/api/haushalt/rezept", json={"text": "2 Kondom"}).json()["ingredients"][0] + assert ing["known"] is False and ing["product"] != "Kondome" + ing = anna.post("/api/haushalt/rezept", json={"text": "2 Kondom"}).json()["ingredients"][0] + assert ing["known"] is True and ing["product"] == "Kondome" + + +def test_vorschlag_loeschen_nur_in_eigenen_listen(login): + anna, ben = login("anna"), login("ben") + home = anna.get("/api/haushalt").json()["lists"][0]["id"] + private_liste(anna) + anna.post("/api/haushalt/items", json={"list": "einkauf", "name": "Schwangerschaftstest", "listId": home}) + # Ben löscht den Vorschlag: weg aus "Zuhause", in Annas privater Liste bleibt er + assert ben.delete("/api/haushalt/produkte", params={"name": "Schwangerschaftstest"}).status_code == 200 + assert "Schwangerschaftstest" not in ben.get("/api/haushalt").json()["products"] + assert "Schwangerschaftstest" in anna.get("/api/haushalt").json()["products"] + # Ein Vorschlag, den Ben nicht sieht, lässt sich auch nicht löschen + assert ben.delete("/api/haushalt/produkte", params={"name": "Schwangerschaftstest"}).status_code == 404 + + +# ---------- Artikel, API-Doku ---------- + + +def test_artikel_nur_eigene_bilder(login): + anna = login("anna") + name = upload(anna, jpeg_mit_gps()) + html = f'

x

ok' + content = anna.post("/api/projects", json={"title": "T", "content": html}).json()["content"] + assert "tracker.example" not in content + assert f"/uploads/{name}" in content + + +def test_api_doku_nicht_oeffentlich(): + client = TestClient(app) + assert client.get("/api/openapi.json").status_code == 404 + assert client.get("/api/docs").status_code == 404 + + +# ---------- Umstellung bestehender Daten (Schritt 2) ---------- + + +def test_umstellung_bestehender_daten(conn, login): + anna = login("anna") + home = anna.get("/api/haushalt").json()["lists"][0]["id"] + with conn: + # Stand vor Schritt 2: Katalog ohne Listen-Zuordnung, Artikel mit fremdem Bild, Foto mit GPS + conn.execute("INSERT INTO shopping_lists (id, name, everyone, position, created_at, created_by) VALUES (99, 'P', 0, 2, 'x', ?)", (anna.user["id"],)) + for key, name in (("geheim", "Geheim"), ("alt", "Alt")): + conn.execute("INSERT INTO products (key, name, uses, last_used) VALUES (?, ?, 3, 'x')", (key, name)) + conn.execute("INSERT INTO household_items (key, name, list, list_id, quantity, unit, updated_at) VALUES ('geheim', 'Geheim', 'einkauf', 99, 1, 'stk', 'x')") + conn.execute("DELETE FROM list_products") + conn.execute("""INSERT INTO projects (id, title, status, content, created_at, updated_at) + VALUES ('a1', 'T', 'aktiv', '

x

', 'x', 'x')""") + conn.execute("PRAGMA user_version = 1") + foto = DATA_DIR / "uploads" / ("ab" * 12 + ".jpg") + foto.write_bytes(jpeg_mit_gps()) + db.init() + rows = {tuple(r) for r in conn.execute("SELECT list_id, key FROM list_products")} + assert rows == {(99, "geheim"), (home, "alt")} + assert "tracker" not in conn.execute("SELECT content FROM projects WHERE id = 'a1'").fetchone()[0] + assert not Image.open(foto).getexif()