Mealie anbinden: Passwort einmal, danach nur noch ein Dauertoken

Unter Einstellungen gibt es jetzt eine Karte "Mealie (Rezepte)": Adresse,
Benutzername, Passwort. Vorrania meldet sich damit EINMAL an
(POST /api/auth/token), laesst sich von Mealie ueber
POST /api/users/api-tokens ein Dauertoken ausstellen und verwirft das
Passwort sofort - es steht in keiner Zeile der Datenbank, in keiner
Antwort und in keinem Fehlertext. Wer sein Passwort gar nicht erst
eintippen will, haekelt "Ich gebe lieber selbst ein Token ein" an.

Das Token liegt NICHT in den Einstellungen: GET /settings liefert jedem
angemeldeten Nutzer alle Zeilen im Klartext. Eine Ausschlussliste dort
waere beim naechsten Geheimnis vergessen, deshalb eine eigene Tabelle ohne
jeden Leseweg - setzen, loeschen, und die Auskunft "ist gesetzt". Was
keinen Ausgang hat, kann nicht durchrutschen. Ein Test prueft genau das.

Der Dienst folgt services/oidc.py, nicht off.py: Bei Open Food Facts ist
"kein Treffer" der Normalfall und wird still zu None, hier ist "Mealie
antwortet nicht" ein Fehler, den der Nutzer sehen muss. Die Kennungen
trennen auch, was verschieden ist - ein falsches Passwort (400) ist etwas
anderes als ein weggetretener Server (502).

Mealie 1.x legt die Haushalts-Routen unter /api/groups/, 2.x unter
/api/households/. Erkannt wird das zur Laufzeit mit einer Anfrage und
dann eine Stunde gepuffert; ueber die Versionsnummer waere es unzuverlaessig,
die melden Installationen uneinheitlich.

Rezeptliste und -abgleich kommen als Naechstes; dies ist der Teil, mit dem
sich der Server ueberhaupt erst verbinden laesst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-02 12:54:43 +02:00
co-authored by Claude Opus 5
parent 37ab1ba47d
commit 7d8a54a3fc
9 changed files with 942 additions and 0 deletions
+3
View File
@@ -39,6 +39,9 @@ class Settings(BaseSettings):
# Kategorie etwas sagen. Im Betrieb entscheidet die Einstellung
# "open_days"; dieser Wert gilt nur, solange dort nichts gesetzt ist.
open_days_default: int = 3
# Mealie: Adresse und Token stehen in der Datenbank (Admin traegt sie in der
# Oberflaeche ein), hier nur die Geduld fuer eine Antwort.
mealie_timeout_seconds: float = 10.0
# CORS (comma separated); "*" allows all
cors_origins: str = "*"
+2
View File
@@ -19,6 +19,7 @@ from .routers import (
loans,
locations,
maintenance,
mealie,
measures,
oidc,
package_types,
@@ -325,6 +326,7 @@ app.include_router(branding.router)
app.include_router(categories.router)
app.include_router(maintenance.router)
app.include_router(measures.router)
app.include_router(mealie.router)
app.include_router(dashboard.router)
app.include_router(shops.router)
app.include_router(field_definitions.router)
+25
View File
@@ -790,6 +790,31 @@ class Setting(Base):
value: Mapped[str] = mapped_column(String(255), nullable=False)
class IntegrationSecret(Base):
"""Zugangsgeheimnis für einen externen Dienst (heute: das Mealie-Dauertoken).
Bewusst **nicht** in :class:`Setting`: ``GET /settings`` liefert jedem
angemeldeten Nutzer alle Zeilen im Klartext. Eine Ausschlussliste dort wäre
beim nächsten Geheimnis vergessen. Diese Tabelle hat deshalb überhaupt
keinen Leseweg nach außen — setzen, löschen, und eine Auskunft „ist
gesetzt". Was keinen Ausgang hat, kann nicht durchrutschen.
Anders als bei :class:`ApiToken` muss der Klartext erhalten bleiben: Wir
legen ihn einem fremden Dienst vor, ein Hash nützte dort nichts. Er liegt
damit unverschlüsselt in der Datenbank — eine Verschlüsselung wäre Theater,
solange der Schlüssel daneben läge. Der Schutz ist: kein Leseweg, nur Admin
beim Setzen, nie in einer Antwort.
"""
__tablename__ = "integration_secrets"
service: Mapped[str] = mapped_column(String(32), primary_key=True)
secret: Mapped[str] = mapped_column(Text, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now
)
class Shop(Base):
"""Bezugsquelle / Geschäft, aus dem ein Gegenstand stammt ("gekauft bei").
+149
View File
@@ -0,0 +1,149 @@
"""Mealie einrichten und prüfen.
Die Zugangsdaten nimmt dieser Router entgegen, gibt sie aber nie zurück. Die
Oberfläche erfährt nur „eingerichtet ja/nein" — dasselbe Muster wie bei
``GET /auth/oidc/providers``, wo Client-Secrets ebenfalls nie in der Antwort
stehen.
Bewusst eigene Routen mit JSON-Rumpf statt ``PUT /settings/{key}?value=``:
Query-Parameter landen in Zugriffsprotokollen, und ein Passwort hat dort
nichts zu suchen.
"""
# ACHTUNG: Kein ``from __future__ import annotations`` – siehe package_types.py.
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Setting, User
from ..schemas import (
MealieAvailable,
MealieConfigIn,
MealieStatus,
MealieTokenIn,
)
from ..services import mealie
router = APIRouter(prefix="/mealie", tags=["mealie"])
def _setze_adresse(db: Session, base_url: str) -> str:
sauber = base_url.strip().rstrip("/")
row = db.get(Setting, mealie.BASE_URL_KEY)
if row is None:
db.add(Setting(key=mealie.BASE_URL_KEY, value=sauber))
else:
row.value = sauber
db.commit()
return sauber
def _status(db: Session, pruefen: bool) -> MealieStatus:
adresse = mealie.basis_adresse(db)
hat_token = bool(mealie.token(db))
status_out = MealieStatus(
configured=bool(adresse) and hat_token,
base_url=adresse,
token_set=hat_token,
)
if not pruefen or not status_out.configured:
return status_out
try:
status_out.version = mealie.version(db)
# Eine echte Abfrage – „erreichbar" soll heissen, dass auch das Token gilt.
mealie.rezepte(db, pro_seite=1)
status_out.reachable = True
except mealie.MealieFehler as fehler:
status_out.reachable = False
status_out.error = str(fehler)
return status_out
@router.get("/status", response_model=MealieStatus)
def mealie_status(
pruefen: bool = False,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> MealieStatus:
"""Was eingerichtet ist – ohne das Token. ``pruefen=true`` fragt Mealie an."""
return _status(db, pruefen)
@router.get("/available", response_model=MealieAvailable)
def mealie_available(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> MealieAvailable:
"""Ob die Rezept-Seite etwas anzeigen kann.
Für alle angemeldeten Benutzer, damit die Oberfläche den Menüpunkt nur dann
zeigt. Anders als bei den Anmeldeanbietern **mit** Anmeldung: Es gibt keinen
Grund, Unbekannten zu verraten, dass hier eine Mealie-Instanz hängt.
"""
return MealieAvailable(available=mealie.eingerichtet(db))
@router.put("/config", response_model=MealieStatus)
def mealie_konfigurieren(
payload: MealieConfigIn,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> MealieStatus:
"""Mit Benutzername und Passwort anmelden und ein Dauertoken holen.
Das Passwort wird nur für diese eine Anmeldung benutzt und danach verworfen
– es steht in keiner Zeile der Datenbank und in keiner Antwort.
"""
try:
dauertoken = mealie.hole_dauertoken(
payload.base_url, payload.username, payload.password
)
except mealie.MealieFehler as fehler:
raise HTTPException(_http_code(fehler), str(fehler)) from fehler
_setze_adresse(db, payload.base_url)
mealie.speichere_token(db, dauertoken)
return _status(db, pruefen=True)
@router.put("/token", response_model=MealieStatus)
def mealie_token_setzen(
payload: MealieTokenIn,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> MealieStatus:
"""Ein in Mealie selbst erzeugtes Dauertoken einsetzen.
Für alle, die ihr Passwort nicht eintippen wollen: In Mealie unter Profil ▸
API-Tokens eines anlegen und hier einfügen.
"""
_setze_adresse(db, payload.base_url)
mealie.speichere_token(db, payload.token.strip())
return _status(db, pruefen=True)
@router.delete("/config", status_code=status.HTTP_204_NO_CONTENT)
def mealie_trennen(
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Verbindung lösen: Adresse und Token hier entfernen.
Das Token selbst bleibt in Mealie bestehen – wer es dort endgültig los sein
will, löscht es unter Profil ▸ API-Tokens.
"""
mealie.loesche_zugang(db)
def _http_code(fehler: mealie.MealieFehler) -> int:
"""Fehlerkennung auf einen Statuscode abbilden.
Getrennt, weil „Mealie ist weg" (502) etwas anderes ist als „dein Passwort
stimmt nicht" (400) – und die Oberfläche beides verschieden erklären soll.
"""
if fehler.kennung in ("anmeldung_fehlgeschlagen",):
return status.HTTP_400_BAD_REQUEST
if fehler.kennung == "nicht_eingerichtet":
return status.HTTP_503_SERVICE_UNAVAILABLE
return status.HTTP_502_BAD_GATEWAY
+34
View File
@@ -694,6 +694,40 @@ class LotConsumeOut(BaseModel):
opened: bool = False
# ---- Mealie ----
class MealieConfigIn(BaseModel):
"""Zugangsdaten für die einmalige Anmeldung bei Mealie.
Das Passwort wird **nicht** gespeichert: Vorrania meldet sich damit einmal
an, lässt sich ein Dauertoken ausstellen und vergisst es.
"""
base_url: str = Field(min_length=1, max_length=255)
username: str = Field(min_length=1, max_length=120)
password: str = Field(min_length=1)
class MealieTokenIn(BaseModel):
"""Ein in Mealie selbst erzeugtes Dauertoken einsetzen (Alternative zum Passwort)."""
base_url: str = Field(min_length=1, max_length=255)
token: str = Field(min_length=1)
class MealieStatus(BaseModel):
"""Auskunft über die Verbindung – **nie** das Token selbst."""
configured: bool = False
base_url: str = ""
token_set: bool = False
# Erst bei einer echten Abfrage gefüllt; None heisst „nicht nachgesehen".
reachable: bool | None = None
version: str | None = None
error: str | None = None
class MealieAvailable(BaseModel):
"""Ob die Rezept-Seite überhaupt etwas anzeigen kann (für alle Benutzer)."""
available: bool = False
# ---- Hausmaße ----
class MeasureIn(BaseModel):
"""Name eines Hausmaßes. Die Menge steht an Kategorie bzw. Artikel."""
+281
View File
@@ -0,0 +1,281 @@
"""Anbindung an eine selbst gehostete Mealie-Instanz (Rezepte).
Vorbild ist ``services/oidc.py``, nicht ``off.py``. Der Unterschied ist
Absicht: Bei Open Food Facts ist „kein Treffer" der Normalfall und wird still
zu ``None``. Hier ist „Mealie antwortet nicht" ein Fehler, den der Nutzer sehen
muss — sonst stünde eine leere Rezeptliste da, und niemand wüsste, ob es keine
Rezepte gibt oder der Server weg ist.
Zugangsdaten: Der Admin tippt Adresse, Benutzername und Passwort **einmal**.
Vorrania meldet sich damit an, lässt sich von Mealie ein Dauertoken ausstellen
und verwirft das Passwort sofort. Gespeichert wird nur das Token — widerrufbar
in Mealie unter Profil ▸ API-Tokens.
"""
from __future__ import annotations
import time
import httpx
from sqlalchemy.orm import Session
from ..config import get_settings
from ..models import IntegrationSecret, Setting
# Schlüssel in den Einstellungen bzw. in der Geheimnis-Tabelle.
BASE_URL_KEY = "mealie_base_url"
SECRET_SERVICE = "mealie"
# Name des Tokens, wie er in Mealie erscheint – damit erkennbar ist, wer da
# zugreift, und man es gezielt widerrufen kann.
TOKEN_NAME = "Vorrania"
CACHE_SEKUNDEN = 3600.0 # erkannte Pfadvariante
REZEPTE_CACHE_SEKUNDEN = 60.0 # kurz: ein neues Rezept soll bald auftauchen
# Pfadvariante je Adresse. Mealie 2.x legt die Haushalts-Routen unter
# /api/households/, 1.x unter /api/groups/. Die Rezeptpfade sind in beiden
# gleich; der Unterschied zählt erst für die Einkaufslisten.
_variante_cache: dict[str, tuple[float, str]] = {}
_rezepte_cache: dict[str, tuple[float, dict]] = {}
# Fuer Tests austauschbar – ohne das muesste jeder Test einen Server starten.
_transport: httpx.BaseTransport | None = None
class MealieFehler(Exception):
"""Mealie hat nicht geantwortet oder abgelehnt.
``kennung`` ist die kurze Kennung für die Oberfläche, ``args[0]`` der
Klartext fürs Protokoll – wie ``OidcFehler``. **Das Passwort steht in
keinem der beiden.**
"""
def __init__(self, kennung: str, text: str) -> None:
super().__init__(text)
self.kennung = kennung
def cache_leeren() -> None:
"""Nur für Tests und zum erzwungenen Neuladen."""
_variante_cache.clear()
_rezepte_cache.clear()
def setze_transport(transport: httpx.BaseTransport | None) -> None:
"""Nur für Tests: HTTP-Antworten vorgeben, ohne einen Server zu starten."""
global _transport
_transport = transport
def _client() -> httpx.Client:
return httpx.Client(
timeout=get_settings().mealie_timeout_seconds,
headers={"User-Agent": "Vorrania-Selfhosted/1.0"},
follow_redirects=True,
transport=_transport,
)
# --------------------------------------------------------------------------
# Konfiguration
# --------------------------------------------------------------------------
def basis_adresse(db: Session) -> str:
row = db.get(Setting, BASE_URL_KEY)
return (row.value if row else "").strip().rstrip("/")
def token(db: Session) -> str:
row = db.get(IntegrationSecret, SECRET_SERVICE)
return row.secret if row else ""
def eingerichtet(db: Session) -> bool:
return bool(basis_adresse(db)) and bool(token(db))
def _zugang(db: Session) -> tuple[str, str]:
basis, tok = basis_adresse(db), token(db)
if not basis or not tok:
raise MealieFehler(
"nicht_eingerichtet",
"Für Mealie sind noch keine Zugangsdaten hinterlegt.",
)
return basis, tok
def speichere_token(db: Session, wert: str) -> None:
row = db.get(IntegrationSecret, SECRET_SERVICE)
if row is None:
db.add(IntegrationSecret(service=SECRET_SERVICE, secret=wert))
else:
row.secret = wert
db.commit()
cache_leeren()
def loesche_zugang(db: Session) -> None:
row = db.get(IntegrationSecret, SECRET_SERVICE)
if row is not None:
db.delete(row)
adresse = db.get(Setting, BASE_URL_KEY)
if adresse is not None:
db.delete(adresse)
db.commit()
cache_leeren()
# --------------------------------------------------------------------------
# Einrichten: Passwort einmal, danach nur noch das Dauertoken
# --------------------------------------------------------------------------
def hole_dauertoken(base_url: str, username: str, password: str) -> str:
"""Einmal anmelden, Dauertoken ausstellen lassen, zurückgeben.
1. ``POST /api/auth/token`` mit Benutzername und Passwort als Formularfelder
liefert ein kurzlebiges Sitzungstoken.
2. Damit ``POST /api/users/api-tokens`` → ein Dauertoken (rund fünf Jahre),
das genau einmal im Klartext in der Antwort steht.
Das Passwort verlässt diese Funktion nicht: Es steht in keinem Rückgabewert,
in keiner Fehlermeldung und in keiner Protokollzeile.
"""
basis = base_url.strip().rstrip("/")
if not basis:
raise MealieFehler("nicht_eingerichtet", "Bitte die Adresse von Mealie angeben.")
with _client() as client:
try:
antwort = client.post(
f"{basis}/api/auth/token",
data={"username": username, "password": password},
)
except httpx.HTTPError as fehler:
raise MealieFehler(
"nicht_erreichbar", f"Mealie unter {basis} nicht erreichbar: {fehler}"
) from fehler
if antwort.status_code in (401, 403):
raise MealieFehler(
"anmeldung_fehlgeschlagen",
"Benutzername oder Passwort stimmen bei Mealie nicht.",
)
if antwort.status_code >= 400:
raise MealieFehler(
"abgelehnt",
f"Mealie hat die Anmeldung abgelehnt (HTTP {antwort.status_code}).",
)
try:
sitzung = antwort.json().get("access_token")
except ValueError:
sitzung = None
if not sitzung:
raise MealieFehler(
"abgelehnt", "Mealie hat kein Sitzungstoken zurückgegeben."
)
try:
erstellt = client.post(
f"{basis}/api/users/api-tokens",
json={"name": TOKEN_NAME, "integration_id": "vorrania"},
headers={"Authorization": f"Bearer {sitzung}"},
)
except httpx.HTTPError as fehler:
raise MealieFehler(
"nicht_erreichbar", f"Mealie nicht erreichbar: {fehler}"
) from fehler
if erstellt.status_code >= 400:
raise MealieFehler(
"kein_dauertoken",
f"Mealie wollte kein Dauertoken ausstellen (HTTP {erstellt.status_code}).",
)
try:
dauertoken = erstellt.json().get("token")
except ValueError:
dauertoken = None
if not dauertoken:
raise MealieFehler(
"kein_dauertoken", "Mealie hat kein Dauertoken zurückgegeben."
)
return dauertoken
# --------------------------------------------------------------------------
# Abfragen
# --------------------------------------------------------------------------
def _hole(db: Session, pfad: str) -> dict:
basis, tok = _zugang(db)
with _client() as client:
try:
antwort = client.get(
f"{basis}{pfad}", headers={"Authorization": f"Bearer {tok}"}
)
except httpx.HTTPError as fehler:
raise MealieFehler(
"nicht_erreichbar", f"Mealie unter {basis} nicht erreichbar: {fehler}"
) from fehler
if antwort.status_code in (401, 403):
raise MealieFehler(
"token_fehlt",
"Mealie weist das hinterlegte Token zurück. Bitte die Verbindung erneuern.",
)
if antwort.status_code == 404:
raise MealieFehler("nicht_gefunden", f"Mealie kennt {pfad} nicht.")
if antwort.status_code >= 400:
raise MealieFehler(
"abgelehnt", f"Mealie antwortete mit HTTP {antwort.status_code} auf {pfad}."
)
try:
return antwort.json()
except ValueError as fehler:
raise MealieFehler("abgelehnt", "Mealie antwortete nicht mit JSON.") from fehler
def pfadvariante(db: Session) -> str:
"""„households" (Mealie 2.x) oder „groups" (1.x), gepuffert je Adresse.
Die Rezeptpfade sind in beiden Fassungen gleich; der Unterschied zählt erst
für die Einkaufslisten. Geprüft wird mit einer Anfrage: 404 heißt „falscher
Pfad", alles andere „richtiger". Bewusst nicht über die Versionsnummer —
die melden Installationen uneinheitlich.
"""
basis = basis_adresse(db)
gepuffert = _variante_cache.get(basis)
if gepuffert and gepuffert[0] > time.monotonic():
return gepuffert[1]
variante = "households"
try:
_hole(db, "/api/households/shopping/lists?perPage=1")
except MealieFehler as fehler:
if fehler.kennung == "nicht_gefunden":
variante = "groups"
elif fehler.kennung != "abgelehnt":
raise
_variante_cache[basis] = (time.monotonic() + CACHE_SEKUNDEN, variante)
return variante
def version(db: Session) -> str | None:
"""Versionsangabe der Instanz, nur zur Anzeige. Fehlt sie, ist das kein Fehler."""
try:
return str(_hole(db, "/api/app/about").get("version") or "") or None
except MealieFehler:
return None
def rezepte(db: Session, *, seite: int = 1, pro_seite: int = 50, suche: str | None = None) -> dict:
"""Eine Seite der Rezeptliste. Kurz gepuffert, damit Blättern nicht hakt."""
pfad = f"/api/recipes?page={seite}&perPage={pro_seite}"
if suche:
pfad += f"&search={httpx.QueryParams({'s': suche})['s']}"
schluessel = f"{basis_adresse(db)}|{pfad}"
gepuffert = _rezepte_cache.get(schluessel)
if gepuffert and gepuffert[0] > time.monotonic():
return gepuffert[1]
daten = _hole(db, pfad)
_rezepte_cache[schluessel] = (time.monotonic() + REZEPTE_CACHE_SEKUNDEN, daten)
return daten
def rezept(db: Session, slug: str) -> dict:
"""Ein einzelnes Rezept samt Zutaten. Bewusst **nicht** gepuffert –
es wird gezielt geöffnet und soll dann aktuell sein."""
return _hole(db, f"/api/recipes/{slug}")