diff --git a/backend/app/models.py b/backend/app/models.py index 75eaac3..01fa653 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -277,6 +277,29 @@ class Movement(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) +class ProductImage(Base): + """Lokale Kopie des Artikelbilds. + + Eigene Tabelle statt einer Spalte an ``products``: Sonst zöge jede + Artikelliste die Bilddaten mit, obwohl sie dort niemand braucht. + + ``source_url`` merkt sich, woher das Bild kam – daran ist erkennbar, ob eine + geänderte ``Product.image_url`` ein neues Bild bedeutet. + """ + + __tablename__ = "product_images" + + product_id: Mapped[int] = mapped_column( + ForeignKey("products.id", ondelete="CASCADE"), primary_key=True + ) + content_type: Mapped[str] = mapped_column(String(64), nullable=False) + data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + source_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_now, onupdate=_now + ) + + class BrandingAsset(Base): """Eigenes Logo bzw. Favicon der Installation. diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index 921f2de..7ca3dd8 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -1,12 +1,14 @@ from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import Response from sqlalchemy.orm import Session from ..crud import product_to_out from ..database import get_db from ..deps import get_current_user, require_admin -from ..models import Barcode, BaseUnit, Category, Group, Product, User +from ..models import Barcode, BaseUnit, Category, Group, Product, ProductImage, User from ..off import lookup_barcode from ..schemas import BarcodeCreate, LookupResult, ProductCreate, ProductOut, ProductUpdate +from ..services import images from ..services.categories import suggest_category from .categories import descendant_ids from ..services.conversion import ConversionError, resolve_product_unit @@ -90,6 +92,34 @@ def get_product( return product_to_out(db, product) +@router.get("/{product_id}/image") +def get_product_image( + product_id: int, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> Response: + """Artikelbild aus der eigenen Datenbank – nie von Open Food Facts. + + Anders als das Logo verlangt diese Route eine Anmeldung: Aus den Bildern + liesse sich sonst ohne Konto ablesen, was im Vorrat liegt. + """ + product = db.get(Product, product_id) + if product is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") + + bild = images.ensure(db, product) + if bild is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Bild vorhanden") + return Response( + content=bild.data, + media_type=bild.content_type, + headers={ + "Cache-Control": "private, max-age=300", + "ETag": f'"bild-{product_id}-{int(bild.updated_at.timestamp())}"', + }, + ) + + @router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED) def create_product( payload: ProductCreate, @@ -129,6 +159,10 @@ def create_product( db.add(product) db.flush() # product.id fuer den Gruppen-Code sync_group_code(db, product) + if product.image_url: + # Gleich beim Anlegen holen. Schlaegt es fehl, entsteht kein Fehler: + # Ein fehlendes Bild darf das Anlegen eines Artikels nicht verhindern. + images.store(db, product, product.image_url) db.commit() db.refresh(product) return product_to_out(db, product) @@ -173,10 +207,20 @@ def update_product( data.pop("date_precision", None) # Spalte ist NOT NULL else: data["date_precision"] = data["date_precision"].value + alte_bildadresse = product.image_url for field, value in data.items(): setattr(product, field, value) # Gruppe oder Barcode koennen sich geaendert haben - Code nachziehen. sync_group_code(db, product) + if "image_url" in data and data["image_url"] != alte_bildadresse: + # Neue Adresse heisst neues Bild – die alte Kopie waere sonst dauerhaft + # falsch, weil ``ensure`` nur nachlaedt, wenn gar keine Kopie da ist. + alt = db.get(ProductImage, product.id) + if alt is not None: + db.delete(alt) + db.flush() + if product.image_url: + images.store(db, product, product.image_url) db.commit() db.refresh(product) return product_to_out(db, product) diff --git a/backend/app/services/images.py b/backend/app/services/images.py new file mode 100644 index 0000000..e3b9e3d --- /dev/null +++ b/backend/app/services/images.py @@ -0,0 +1,92 @@ +"""Artikelbilder holen und lokal vorhalten. + +Open Food Facts liefert nur eine Bild-*Adresse*. Würde die Oberfläche direkt +dorthin verlinken, hinge jede Artikelseite an einem fremden Dienst: Das Bild +verschwindet, wenn OFF es austauscht oder nicht erreichbar ist, die Installation +funktioniert nicht mehr offline, und jeder Seitenaufruf verrät OFF, welche +Artikel jemand ansieht. Deshalb wird das Bild einmal geholt und danach aus der +eigenen Datenbank ausgeliefert. + +Die Bilder liegen in einer eigenen Tabelle und nicht als Spalte an ``products``: +Sonst zöge jede Artikelliste die Blobs mit. +""" + +from __future__ import annotations + +import httpx +from sqlalchemy.orm import Session + +from ..models import Product, ProductImage + +# Grosszuegig genug fuer ein Produktfoto, eng genug, dass niemand die Datenbank +# mit einer versehentlich verlinkten Datei volllaeuft. +MAX_BYTES = 2 * 1024 * 1024 + +ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} + +TIMEOUT_SECONDS = 15.0 + + +def fetch(url: str) -> tuple[bytes, str] | None: + """Bild herunterladen. Gibt (Daten, Inhaltstyp) zurück oder None. + + Fehler sind hier bewusst kein Ausnahmefall, der nach oben durchschlägt: Ein + fehlendes Bild darf niemals das Anlegen eines Artikels verhindern. + """ + if not url or not url.startswith(("http://", "https://")): + return None + try: + resp = httpx.get( + url, + timeout=TIMEOUT_SECONDS, + follow_redirects=True, + headers={"User-Agent": "Vorrania-Selfhosted/1.0"}, + ) + except httpx.HTTPError: + return None + if resp.status_code != 200: + return None + + typ = (resp.headers.get("content-type") or "").split(";")[0].strip().lower() + if typ not in ALLOWED_TYPES: + return None + daten = resp.content + if not daten or len(daten) > MAX_BYTES: + return None + return daten, typ + + +def store(db: Session, product: Product, url: str) -> ProductImage | None: + """Bild holen und beim Artikel ablegen. Ohne Erfolg bleibt alles wie es war.""" + geholt = fetch(url) + if geholt is None: + return None + daten, typ = geholt + + bild = db.get(ProductImage, product.id) + if bild is None: + bild = ProductImage( + product_id=product.id, content_type=typ, data=daten, source_url=url + ) + db.add(bild) + else: + bild.content_type, bild.data, bild.source_url = typ, daten, url + return bild + + +def ensure(db: Session, product: Product) -> ProductImage | None: + """Lokale Kopie zurückgeben und bei Bedarf einmalig nachholen. + + So bekommen auch Artikel ein lokales Bild, die vor dieser Funktion angelegt + wurden – ohne Wanderung über alle Datensätze. Bezahlt wird das mit einer + einmaligen Verzögerung beim ersten Aufruf. + """ + bild = db.get(ProductImage, product.id) + if bild is not None: + return bild + if not product.image_url: + return None + bild = store(db, product, product.image_url) + if bild is not None: + db.commit() + return bild diff --git a/backend/tests/test_product_images.py b/backend/tests/test_product_images.py new file mode 100644 index 0000000..4e186fa --- /dev/null +++ b/backend/tests/test_product_images.py @@ -0,0 +1,53 @@ +from app.models import BaseUnit, Product, ProductImage +from app.services import images + +EIN_PIXEL = b"\x89PNG\r\n\x1a\n-nicht-echt-aber-egal" + + +def _artikel(db, url=None): + p = Product(name="Pesto", base_unit=BaseUnit.gram, package_size=195, image_url=url) + db.add(p) + db.commit() + db.refresh(p) + return p + + +def test_ohne_bildadresse_gibt_es_nichts_zu_holen(db, monkeypatch): + monkeypatch.setattr(images, "fetch", lambda url: (_ for _ in ()).throw(AssertionError)) + assert images.ensure(db, _artikel(db)) is None + + +def test_bild_wird_einmal_geholt_und_danach_lokal_geliefert(db, monkeypatch): + aufrufe = [] + + def gefaelscht(url): + aufrufe.append(url) + return EIN_PIXEL, "image/png" + + monkeypatch.setattr(images, "fetch", gefaelscht) + artikel = _artikel(db, "https://example.org/pesto.png") + + erstes = images.ensure(db, artikel) + assert erstes is not None + assert erstes.data == EIN_PIXEL + assert erstes.content_type == "image/png" + + # Zweiter Aufruf darf nicht erneut ins Netz gehen - genau das ist der Zweck. + images.ensure(db, artikel) + assert aufrufe == ["https://example.org/pesto.png"] + + +def test_ein_nicht_erreichbares_bild_bleibt_folgenlos(db, monkeypatch): + monkeypatch.setattr(images, "fetch", lambda url: None) + artikel = _artikel(db, "https://example.org/weg.png") + assert images.ensure(db, artikel) is None + assert db.get(ProductImage, artikel.id) is None + + +def test_fetch_geht_nur_an_http_adressen(monkeypatch): + """Ohne diese Schranke waere eine 'file://'-Adresse ein Weg ins Dateisystem.""" + monkeypatch.setattr( + images.httpx, "get", lambda *a, **k: (_ for _ in ()).throw(AssertionError) + ) + assert images.fetch("file:///etc/passwd") is None + assert images.fetch("") is None diff --git a/web/src/api.js b/web/src/api.js index e47be72..91ef106 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -82,6 +82,25 @@ function zeitstempel() { + `_${zwei(jetzt.getHours())}${zwei(jetzt.getMinutes())}`; } +/** + * Bild mit Anmeldung laden und als lokale Objekt-URL zurückgeben. + * + * Nötig, weil ein keinen Authorization-Header mitschickt. Ohne + * diesen Umweg müsste die Bildroute offen sein – dann liesse sich ohne Konto + * ablesen, was im Vorrat liegt. + * + * Gibt null zurück, wenn es kein Bild gibt; das ist der Normalfall und kein + * Fehler. Wer die URL nicht mehr braucht, gibt sie mit URL.revokeObjectURL frei. + */ +export async function authorizedObjectUrl(path) { + const token = getToken(); + const resp = await fetch(`${API_BASE}${path}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!resp.ok) return null; + return URL.createObjectURL(await resp.blob()); +} + // Datei mit Auth-Header laden und im Browser als Download anbieten. export async function downloadFile(path, filename) { const token = getToken(); diff --git a/web/src/components/ProduktBild.jsx b/web/src/components/ProduktBild.jsx new file mode 100644 index 0000000..52c5e57 --- /dev/null +++ b/web/src/components/ProduktBild.jsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; +import { authorizedObjectUrl } from "../api"; + +/** + * Artikelbild aus der eigenen Datenbank. + * + * Rendert nichts, solange kein Bild vorliegt – ein Platzhalter für einen + * Artikel, der nie ein Bild bekommen wird, wäre nur Lärm. + * + * Die Objekt-URL wird beim Verlassen wieder freigegeben, sonst hält der Browser + * jedes angesehene Bild bis zum Neuladen der Seite im Speicher. + */ +export default function ProduktBild({ productId, alt, className = "" }) { + const [url, setUrl] = useState(null); + + useEffect(() => { + if (!productId) return undefined; + let abgebrochen = false; + let erzeugt = null; + + authorizedObjectUrl(`/products/${productId}/image`) + .then((neu) => { + if (!neu) return; + if (abgebrochen) { + URL.revokeObjectURL(neu); + return; + } + erzeugt = neu; + setUrl(neu); + }) + .catch(() => {}); + + return () => { + abgebrochen = true; + if (erzeugt) URL.revokeObjectURL(erzeugt); + setUrl(null); + }; + }, [productId]); + + if (!url) return null; + return {alt; +} diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index 5632382..efe1911 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -5,6 +5,7 @@ import { useConfirm } from "../confirm"; import { useAuth } from "../auth"; import BarcodeList from "../components/BarcodeList"; import Icon from "../components/Icon"; +import ProduktBild from "../components/ProduktBild"; import { useToast } from "../toast"; import { useSettings } from "../settings"; import { asTree } from "../categoryTree"; @@ -263,25 +264,34 @@ export default function ProductForm() {
-
- - {isAdmin && ( - - )} + {/* Bild steht neben den Kennfeldern, nicht darueber: Der Platz rechts + von Barcode, Name und Marke ist ohnehin selten ausgenutzt. Fehlt + ein Bild, rendert die Komponente nichts und die Felder nehmen die + volle Breite ein. */} +
+
+
+ + {isAdmin && ( + + )} +
+ + +
+ {!isNew && }
- -