"""Flask app — v0.2 foundation. Adds Authentik OIDC + mariadb DB + Fernet crypto for per-user Mealie tokens. v0.1 admin endpoints stay (still bearer-gated for now); user-facing routes start using OIDC sessions. Routes (current): GET /healthz liveness, no auth GET / redirects to /login if no session, else /me GET /login start OIDC flow GET /auth/callback OIDC callback POST /logout clear session GET /me "who am I" page (json for now) GET /connect-mealie prompt for Mealie token POST /connect-mealie store encrypted token POST /disconnect-mealie delete encrypted token GET /api/recipes (admin bearer) proxy Mealie list POST /api/sterilize/preview/ (admin bearer) v0.1 sterilizer POST /api/sterilize/apply/ (admin bearer) v0.1 sterilizer """ from functools import wraps from flask import Flask, jsonify, redirect, render_template_string, request, session, url_for from .config import load from .crypto import TokenCrypto from .db import DB from .forge import Forge from .mealie import Mealie, MealieError from .oidc import init_oauth from .sterilizer import Sterilizer cfg = load() db = DB(host=cfg.db_host, port=cfg.db_port, name=cfg.db_name, user=cfg.db_user, password=cfg.db_password) crypto = TokenCrypto(cfg.fernet_key) forge = Forge( base_url=cfg.clawdforge_url, token=cfg.clawdforge_token, default_model=cfg.default_model, default_timeout=cfg.default_timeout_secs, ) # System-tier Mealie client (Alice's "Cauldron" token; admin batch ops only) system_mealie = Mealie(base_url=cfg.mealie_base_url, api_token=cfg.mealie_api_token) system_sterilizer = Sterilizer(mealie=system_mealie, forge=forge, model=cfg.default_model) def create_app() -> Flask: app = Flask(__name__) app.secret_key = cfg.secret_key app.config.update( SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", # NOT setting SESSION_COOKIE_SECURE=True — LAN is plain HTTP for now. # If we ever front this with TLS, flip secure=True. ) # Apply migrations on startup applied = db.migrate() if applied: app.logger.info("applied migrations: %s", applied) oauth = init_oauth( app, issuer=cfg.oidc_issuer, client_id=cfg.oidc_client_id, client_secret=cfg.oidc_client_secret, ) # ---------- helpers -------------------------------------------------- def require_bearer(fn): @wraps(fn) def w(*a, **kw): auth = request.headers.get("Authorization", "") if not auth.startswith("Bearer "): return jsonify({"error": "missing bearer"}), 401 tok = auth[7:].strip() if not _const_eq(tok, cfg.admin_bearer): return jsonify({"error": "forbidden"}), 403 return fn(*a, **kw) return w def require_session(fn): @wraps(fn) def w(*a, **kw): if not session.get("user"): return redirect(url_for("login", next=request.path)) return fn(*a, **kw) return w def current_user_mealie() -> Mealie | None: u = session.get("user") if not u: return None blob = db.get_user_mealie_token_blob(u["sub"]) if not blob: return None try: tok = crypto.decrypt(blob) except Exception: return None return Mealie(base_url=cfg.mealie_base_url, api_token=tok) # ---------- public --------------------------------------------------- @app.get("/healthz") def healthz(): upstream = {} try: upstream["clawdforge"] = forge.healthz() except Exception as e: upstream["clawdforge_error"] = str(e) try: with db.conn() as c, c.cursor() as cur: cur.execute("SELECT 1 AS ok") cur.fetchone() upstream["db"] = "ok" except Exception as e: upstream["db_error"] = str(e) return jsonify({"ok": True, "upstream": upstream}) @app.get("/") def index(): if not session.get("user"): return redirect(url_for("login")) return redirect(url_for("me")) @app.get("/login") def login(): # Stash where to go after login nxt = request.args.get("next") or "/me" session["post_login_next"] = nxt return oauth.cauldron.authorize_redirect(cfg.oidc_redirect_uri) @app.get("/auth/callback") def auth_callback(): token = oauth.cauldron.authorize_access_token() userinfo = token.get("userinfo") or oauth.cauldron.userinfo(token=token) sub = userinfo.get("sub") or userinfo.get("email") email = userinfo.get("email") or sub name = userinfo.get("name") or userinfo.get("preferred_username") if not sub or not email: return ("missing sub/email in userinfo", 400) db.upsert_user(sub=sub, email=email, display_name=name) session["user"] = {"sub": sub, "email": email, "name": name} return redirect(session.pop("post_login_next", "/me")) @app.post("/logout") def logout(): session.clear() return redirect(url_for("login")) @app.get("/me") @require_session def me(): u = session["user"] connected = db.get_user_mealie_token_blob(u["sub"]) is not None mealie_user = None if connected: client = current_user_mealie() if client: try: mealie_user = client.who_am_i() except Exception: mealie_user = None return render_template_string( ME_TEMPLATE, user=u, connected=connected, mealie_user=mealie_user, css=_PALETTE_CSS ) @app.get("/me.json") @require_session def me_json(): u = session["user"] connected = db.get_user_mealie_token_blob(u["sub"]) is not None return jsonify({"user": u, "mealie_connected": connected}) # ---------- mealie connect flow -------------------------------------- @app.get("/connect-mealie") @require_session def connect_mealie_get(): u = session["user"] return render_template_string( CONNECT_TEMPLATE, user=u, mealie_url=cfg.mealie_base_url, css=_PALETTE_CSS, ) @app.post("/connect-mealie") @require_session def connect_mealie_post(): u = session["user"] token = (request.form.get("mealie_token") or "").strip() if not token: return ("empty token", 400) # Validate against Mealie before storing — don't persist a bad token test = Mealie(base_url=cfg.mealie_base_url, api_token=token) try: test.who_am_i() except MealieError as e: return (f"token rejected by Mealie: {e}", 400) blob = crypto.encrypt(token) db.set_user_mealie_token_blob(u["sub"], blob) return redirect(url_for("me")) @app.post("/disconnect-mealie") @require_session def disconnect_mealie(): u = session["user"] db.delete_user_mealie_token(u["sub"]) return redirect(url_for("me")) # ---------- v0.1 admin endpoints (carry over) ------------------------ @app.get("/api/recipes") @require_bearer def list_recipes(): page = int(request.args.get("page", "1")) per_page = min(int(request.args.get("per_page", "50")), 200) return jsonify(system_mealie.list_recipes(page=page, per_page=per_page)) @app.post("/api/sterilize/preview/") @require_bearer def sterilize_preview(slug: str): try: return jsonify(system_sterilizer.preview_recipe(slug)) except Exception as e: return jsonify({"error": str(e)}), 502 @app.post("/api/sterilize/apply/") @require_bearer def sterilize_apply(slug: str): create_missing = request.args.get("create_missing", "true").lower() == "true" try: return jsonify(system_sterilizer.apply_recipe(slug, create_missing=create_missing)) except Exception as e: return jsonify({"error": str(e)}), 502 return app _PALETTE_CSS = """ @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&display=swap'); * { box-sizing: border-box; } body { font-family: system-ui, -apple-system, sans-serif; background: #1f2d1f; color: #f0e6cc; max-width: 720px; margin: 0 auto; padding: 3em 1.5em; line-height: 1.65; } h1, h2 { font-family: 'Cormorant Garamond', Georgia, serif; font-weight: 500; color: #88a87a; margin: 0 0 .2em 0; letter-spacing: 0.01em; } h1 { font-size: 2.6em; } h2 { font-size: 1.6em; color: #6b8e5a; margin-top: 1.6em; } .lede { color: #ddd4ba; font-size: 1.05em; } a { color: #88a87a; text-decoration: none; border-bottom: 1px dotted #4d5d3a; } a:hover { color: #ddd4ba; border-bottom-color: #88a87a; } .panel { background: #2d3a2a; border: 1px solid #3a4a35; padding: 1.2em 1.4em; margin: 1.4em 0; border-radius: 3px; } .panel-row { display: flex; justify-content: space-between; align-items: baseline; gap: 1em; } .muted { color: #6b8e5a; font-size: .9em; } .kv { display: grid; grid-template-columns: max-content 1fr; gap: .4em 1em; margin: .8em 0; } .kv dt { color: #6b8e5a; font-size: .9em; } .kv dd { margin: 0; color: #f0e6cc; font-family: ui-monospace, Menlo, monospace; font-size: .92em; word-break: break-all; } .btn { display: inline-block; padding: .55em 1.3em; background: #6b8e5a; color: #1f2d1f !important; border: none; font-weight: 600; cursor: pointer; font-size: .95em; border-bottom: none; } .btn:hover { background: #88a87a; color: #1f2d1f !important; } .btn-secondary { background: transparent; color: #88a87a !important; border: 1px solid #4d5d3a; } .btn-secondary:hover { background: #2d3a2a; color: #ddd4ba !important; border-color: #88a87a; } .btn-row { display: flex; gap: .8em; flex-wrap: wrap; align-items: center; } input[type=text] { width: 100%; padding: .65em; background: #2d3a2a; border: 1px solid #4d5d3a; color: #f0e6cc; font-family: ui-monospace, Menlo, monospace; font-size: .92em; } input[type=text]:focus { outline: none; border-color: #88a87a; } ol li, ul li { margin: .4em 0; } code { background: #1f2d1f; padding: .1em .45em; border: 1px solid #3a4a35; font-family: ui-monospace, Menlo, monospace; font-size: .9em; } .status-ok { color: #88a87a; } .status-warn { color: #c9b27c; } .brand { color: #6b8e5a; font-family: 'Cormorant Garamond', serif; font-size: .95em; letter-spacing: .15em; text-transform: uppercase; margin-bottom: .2em; } hr { border: none; border-top: 1px solid #3a4a35; margin: 2em 0; } """ ME_TEMPLATE = """ Cauldron
Cauldron

{{ user.name or user.email }}

Welcome back.

Your account

email
{{ user.email }}
subject
{{ user.sub }}

Mealie

{% if connected %}connected{% else %}not connected{% endif %}
{% if connected and mealie_user %}
logged in as
{{ mealie_user.username or mealie_user.email }}
full name
{{ mealie_user.fullName or '—' }}
admin
{{ 'yes' if mealie_user.admin else 'no' }}
Revoking on Mealie's side also works.
{% else %}

Connect your Mealie account so cauldron can act on your behalf — your recipes, your meal plan, your shopping list.

Connect Mealie

{% endif %}

""" CONNECT_TEMPLATE = """ Connect Mealie — Cauldron
Cauldron

Connect your Mealie

Hi {{ user.name or user.email }}. Cauldron acts on Mealie using your own API token. One-time, ~30 seconds.

  1. Open Mealie → API Tokens
  2. Click New API Token, name it cauldron, integration generic
  3. Copy the long token string
  4. Paste below and click Connect

Cancel

Stored encrypted at rest with a Fernet key that lives only in cauldron's env. Revoke anytime in Mealie's UI — cauldron will detect and re-prompt.

""" def _const_eq(a: str, b: str) -> bool: if len(a) != len(b): return False diff = 0 for x, y in zip(a.encode(), b.encode()): diff |= x ^ y return diff == 0 # gunicorn entrypoint app = create_app()