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

+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};
-- 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
View File
@@ -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)
+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.
"""
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)
+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.
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
View File
@@ -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
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)
+1
View File
@@ -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
+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()