Datenschutz: Bild-Metadaten, private Vorschläge, fremde Bilder, API-Doku

- 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 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 18:48:19 +02:00
1 parent 419d200bf9
commit bb20135876
11 files changed
+367 -61

No files matched your search

+16 -12
View File
@@ -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)