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:
1 parent
419d200bf9
commit
bb20135876
11 files changed
+367
-61
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
+38
-14
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
+4
-29
@@ -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"<img(?![^>]*\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}
|
||||
|
||||
|
||||
|
||||
+16
-12
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user