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
+362 -56

No files matched your search

+13 -3
View File
@@ -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 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. 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`) ## 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. Einfügen (Strg+V) oder Drag & Drop in den Text setzen.
Nicht mehr verwendete Bilder werden automatisch gelöscht. Der Artikeltext wird beim 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 ### 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. 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“ - Beim Update werden der bisherige Vorrat und die bisherige Einkaufsliste zur Liste „Zuhause“
(für alle sichtbar). (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 - Welche Liste gewählt ist, merkt sich der Browser. Gekauft, aufgebraucht, Nachkaufen und der
Rezeptabgleich wirken immer innerhalb der gewählten Liste. Rezeptabgleich wirken immer innerhalb der gewählten Liste.
- **Einkaufen:** Abhaken legt ein Produkt „in den Wagen“ – es rutscht nach unten, bleibt aber auf der - **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/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/migrations.py Nummerierte Umstellungen bestehender Datenbanken (Regel steht oben in der Datei)
app/auth.py Konten, Anmeldung, Benutzerverwaltung 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/links.py Schnellzugriff-Links pro Konto
app/monitoring.py Monitoring pro Konto (HTTP/TCP-Prüfung, P42-S.M.-Auslastung + Verlauf) 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 app/haushalt.py Listen (je Vorrat + Einkauf) mit Sichtbarkeit, Produktkatalog, Einheiten
+54
View File
@@ -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ß.")
+10 -1
View File
@@ -138,7 +138,7 @@ CREATE TABLE IF NOT EXISTS shopping_list_members (
CREATE TABLE IF NOT EXISTS household_items {HOUSEHOLD_TABLE}; 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 ( CREATE TABLE IF NOT EXISTS products (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -146,6 +146,15 @@ CREATE TABLE IF NOT EXISTS products (
last_used TEXT NOT NULL 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") -- Gelernte Zuordnungen Rezeptzutat → Produkt (z. B. "margarine" → "Butter")
CREATE TABLE IF NOT EXISTS ingredient_aliases ( CREATE TABLE IF NOT EXISTS ingredient_aliases (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
+33 -9
View File
@@ -82,20 +82,39 @@ def _sort_key(name: str) -> str:
return _key(name).translate(_UMLAUTS) return _key(name).translate(_UMLAUTS)
def _canonical_name(conn, name: str) -> str: def _canonical_name(conn, name: str, list_id=None) -> str:
"""Schreibweise aus dem Katalog übernehmen bzw. neues Produkt aufnehmen.""" """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() row = conn.execute("SELECT name FROM products WHERE key = ?", (_key(name),)).fetchone()
if row: if row:
conn.execute("UPDATE products SET uses = uses + 1, last_used = ? WHERE key = ?", (now_iso(), _key(name))) conn.execute("UPDATE products SET uses = uses + 1, last_used = ? WHERE key = ?", (now_iso(), _key(name)))
return row["name"] name = row["name"]
else:
if conn.execute("SELECT COUNT(*) FROM products").fetchone()[0] >= MAX_PRODUCTS: if conn.execute("SELECT COUNT(*) FROM products").fetchone()[0] >= MAX_PRODUCTS:
raise ValidationError("Der Produktkatalog ist voll. Bitte alte Vorschläge löschen.") 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 # Neue Produkte mit großem Anfangsbuchstaben, damit "milch" und "Milch" nicht doppelt entstehen
name = name[0].upper() + name[1:] name = name[0].upper() + name[1:]
conn.execute("INSERT INTO products (key, name, uses, last_used) VALUES (?, ?, 1, ?)", (_key(name), name, now_iso())) 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 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: 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. """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))})""", WHERE i.list_id IN ({','.join('?' * len(ids))})""",
ids, ids,
).fetchall() ).fetchall()
# Häufig genutzte Produkte zuerst vorschlagen
products = conn.execute("SELECT name, uses FROM products").fetchall()
return { return {
"items": [_item(r) for r in sorted(items, key=lambda r: _sort_key(r["name"]))], "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], "lists": [_shopping_list(conn, r, user) for r in lists],
"users": user_list(conn), "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) quantity = _quantity(data.get("quantity", 1), unit)
with transaction() as conn: with transaction() as conn:
list_id = _get_list(conn, data.get("listId"), user)["id"] 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) return _state(conn, user)
@@ -343,9 +360,16 @@ def delete_list(list_id: int, user: dict = Depends(current_user)):
@router.delete("/api/haushalt/produkte") @router.delete("/api/haushalt/produkte")
def delete_product(name: str, user: dict = Depends(current_user)): 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: 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: if not cur.rowcount:
raise HTTPException(404, "Vorschlag nicht gefunden.") 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) return _state(conn, user)
+32
View File
@@ -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"<img(?![^>]*\ssrc=)[^>]*>", "", cleaned).strip()
+9 -1
View File
@@ -4,6 +4,7 @@ Liefert die API unter /api, die hochgeladenen Bilder unter /uploads
und (falls gebaut) das React-Frontend aus frontend/dist. und (falls gebaut) das React-Frontend aus frontend/dist.
""" """
import asyncio import asyncio
import os
import re import re
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -33,7 +34,14 @@ async def lifespan(_: FastAPI):
capitals.cancel() 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) # Komprimiert u. a. die JS-Bundles und die Weltkarte (~740 KB → ~200 KB)
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(GZipMiddleware, minimum_size=1000)
+45 -1
View File
@@ -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. 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). 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 .db import HOUSEHOLD_TABLE, KANBAN_PROJECTS_TABLE, KANBAN_TASKS_TABLE
from .htmlclean import sanitize
log = logging.getLogger(__name__)
def start(conn) -> None: def start(conn) -> None:
@@ -176,7 +184,43 @@ def _kanban_projects(conn) -> None:
conn.execute("DROP TABLE kanban_tasks_alt") 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. # Reihenfolge = Versionsnummer (Index + 1). Nur anhängen, nie umsortieren oder entfernen.
STEPS = [ STEPS = [
_v1_altbestand, _v1_altbestand,
_v2_datenschutz,
] ]
+4 -29
View File
@@ -3,14 +3,15 @@ import re
import secrets import secrets
import time import time
import nh3
from fastapi import APIRouter, Body, Depends, HTTPException, Request from fastapi import APIRouter, Body, Depends, HTTPException, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from .auth import current_user from .auth import current_user
from .bilder import ohne_metadaten
from .config import DATA_DIR from .config import DATA_DIR
from .common import ValidationError, clean_text, now_iso from .common import ValidationError, clean_text, now_iso
from .db import transaction from .db import transaction
from .htmlclean import sanitize
from .kanban import boards_for_article from .kanban import boards_for_article
router = APIRouter() router = APIRouter()
@@ -26,33 +27,6 @@ MAX_UPLOAD = 15 * 1024 * 1024
MIME = {"jpg": "image/jpeg", "png": "image/png", "gif": "image/gif", "webp": "image/webp"} 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"<img(?![^>]*\ssrc=)[^>]*>", "", cleaned).strip()
# ---------- Validierung ---------- # ---------- Validierung ----------
def _text(value, field: str, max_len: int, required: bool = False) -> str: 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: if not ext:
raise ValidationError("Bildformat nicht unterstützt (erlaubt: JPG, PNG, GIF, WebP).") raise ValidationError("Bildformat nicht unterstützt (erlaubt: JPG, PNG, GIF, WebP).")
name = f"{secrets.token_hex(12)}.{ext}" 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} return {"file": name}
+16 -12
View File
@@ -16,7 +16,7 @@ from fastapi import APIRouter, Body, Depends
from .auth import current_user from .auth import current_user
from .db import transaction 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 from .common import ValidationError
router = APIRouter() router = APIRouter()
@@ -245,10 +245,13 @@ def _stem(name: str) -> str:
return s return s
def _matcher(conn): def _matcher(conn, user: dict):
aliases = {r["key"]: r["product"] for r in conn.execute("SELECT key, product FROM ingredient_aliases")} # Nur Produkte aus Listen, die das Konto sehen darf – sonst verriete die Zuordnung fremde private Einträge
products = [r["name"] for r in conn.execute("SELECT name FROM products")] products = visible_products(conn, user)
exact = {_key(p): p for p in products} 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 = {} stems = {}
for p in products: for p in products:
stems.setdefault(_stem(p), p) stems.setdefault(_stem(p), p)
@@ -278,11 +281,11 @@ def _matcher(conn):
return match 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] parsed = [p for p in (parse_ingredient(r) for r in raw_ingredients[:MAX_INGREDIENTS]) if p]
if not parsed: if not parsed:
raise ValidationError("Keine Zutaten gefunden.") raise ValidationError("Keine Zutaten gefunden.")
match = _matcher(conn) match = _matcher(conn, user)
return { return {
"title": title, "title": title,
"source": source, "source": source,
@@ -295,7 +298,7 @@ def _result(conn, title, source, servings, raw_ingredients) -> dict:
@router.post("/api/haushalt/rezept") @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.""" """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 "" url = data.get("url").strip() if isinstance(data.get("url"), str) else ""
text = data.get("text") if isinstance(data.get("text"), 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() ingredients = ingredients.splitlines()
title = html.unescape(str(recipe.get("name") or "Rezept"))[:200] title = html.unescape(str(recipe.get("name") or "Rezept"))[:200]
with transaction() as conn: 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 text.strip():
if len(text) > MAX_TEXT: if len(text) > MAX_TEXT:
raise ValidationError("Der Text ist zu lang.") 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 servings = servings if isinstance(servings, int) and 0 < servings <= 100 else None
lines = [line.strip(" \t-•*·") for line in text.splitlines()] lines = [line.strip(" \t-•*·") for line in text.splitlines()]
with transaction() as conn: 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.") 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)) 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)] learned = [(_clean_name(a.get("ingredient")), _clean_name(a.get("product"))) for a in aliases if isinstance(a, dict)]
with transaction() as conn: 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: for ingredient, product in learned:
product = _canonical_name(conn, product) product = _canonical_name(conn, product, list_id)
if _key(ingredient) == _key(product): if _key(ingredient) == _key(product):
conn.execute("DELETE FROM ingredient_aliases WHERE key = ?", (_key(ingredient),)) conn.execute("DELETE FROM ingredient_aliases WHERE key = ?", (_key(ingredient),))
else: else:
@@ -354,5 +358,5 @@ def take_over(data: dict = Body(...), user: dict = Depends(current_user)):
(_key(ingredient), product), (_key(ingredient), product),
) )
for name, quantity, unit in checked: 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) return _state(conn, user)
+1
View File
@@ -2,3 +2,4 @@ fastapi==0.143.0
uvicorn[standard]==0.54.0 uvicorn[standard]==0.54.0
httpx==0.28.1 httpx==0.28.1
nh3==0.3.7 nh3==0.3.7
pillow==12.3.0
+145
View File
@@ -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'<p>x</p><img src="https://tracker.example/p.gif"><img src="/uploads/{name}" alt="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', '<p>x</p><img src="https://tracker.example/p.gif">', '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()