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
+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