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
;
+}
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() {