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,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