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:
co-authored by
Claude Opus 5
parent
37ab1ba47d
commit
7d8a54a3fc
@@ -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}")
|
||||
Reference in New Issue
Block a user