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
x