Files
Vorrania/backend/app/services/mealie.py
T
ScarriffleandClaude Opus 5 7d8a54a3fc 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>
2026-10-02 12:54:43 +02:00

282 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}")