"""Member Dashboard — read-only usage view + API-key management. Security model: - Authentication is done by Cloudron's proxyAuth wall (SSO). Cloudron injects the authenticated user's identity via the X-Remote-User header (username). - This app holds NO privileged credentials. It calls the member portal's broker endpoints, authenticated with a shared BROKER_SECRET, and scopes every request to the logged-in member. Environment (all provided by Cloudron env vars): PORTAL_BASE — base URL of the member portal (e.g. https://portal.inference.coop) BROKER_SECRET — shared secret for the portal's broker endpoints """ import os import logging import re import httpx from fastapi import FastAPI, Request, HTTPException from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse logging.basicConfig(level=logging.INFO) logger = logging.getLogger("member-dashboard") PORTAL_BASE = os.environ.get("PORTAL_BASE", "").rstrip("/") BROKER_SECRET = os.environ.get("BROKER_SECRET", "") app = FastAPI() # --------------------------------------------------------------------------- # Identity — Cloudron proxyAuth injects the authenticated user's USERNAME. # --------------------------------------------------------------------------- def get_user_identity(request: Request) -> str: """Return the logged-in user's identity (Cloudron username, or email if Cloudron happens to send one). Cloudron's proxyAuth injects the USERNAME (e.g. "ntnsndr") via X-Remote-User, not the email. We pass it through unchanged to the broker, which resolves username → email (it holds the Cloudron admin token). """ for header in ( "x-remote-user", "x-forwarded-user", "x-auth-request-user", "x-auth-request-email", "x-forwarded-email", ): val = request.headers.get(header) if val: return val.strip() return "" # --------------------------------------------------------------------------- # Broker calls (to the member portal) # --------------------------------------------------------------------------- def broker_headers(identity: str) -> dict: return {"X-Broker-Secret": BROKER_SECRET, "X-Member-User": identity} async def broker_get(identity: str, path: str) -> dict: async with httpx.AsyncClient(timeout=20.0) as client: r = await client.get(f"{PORTAL_BASE}{path}", headers=broker_headers(identity)) if r.status_code == 401: raise HTTPException(500, "Portal rejected broker credentials") if r.status_code == 403: raise HTTPException(403, "Not an active member") if r.status_code != 200: raise HTTPException(502, f"Portal error {r.status_code}") return r.json() async def broker_post(identity: str, path: str, payload: dict | None = None) -> dict: async with httpx.AsyncClient(timeout=20.0) as client: r = await client.post(f"{PORTAL_BASE}{path}", headers=broker_headers(identity), json=payload or {}) if r.status_code in (401, 403): raise HTTPException(r.status_code, r.text) if r.status_code not in (200, 201): raise HTTPException(502, f"Portal error {r.status_code}: {r.text}") return r.json() async def broker_delete(identity: str, path: str) -> dict: async with httpx.AsyncClient(timeout=20.0) as client: r = await client.delete(f"{PORTAL_BASE}{path}", headers=broker_headers(identity)) if r.status_code in (401, 403): raise HTTPException(r.status_code, r.text) if r.status_code not in (200, 204): raise HTTPException(502, f"Portal error {r.status_code}") return r.json() if r.content else {} # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @app.get("/healthz") async def healthz(): return {"status": "ok"} @app.get("/") async def index(request: Request): identity = get_user_identity(request) if not identity: return HTMLResponse( "
Please log in via the dashboard login.
", status_code=401, ) return HTMLResponse(render_page(identity)) @app.get("/api/usage") async def api_usage(request: Request): identity = get_user_identity(request) if not identity: return JSONResponse({"error": "unauthenticated"}, status_code=401) try: data = await broker_get(identity, "/broker/usage") keys = await broker_get(identity, "/broker/keys") data["keys"] = keys.get("keys", []) return data except HTTPException as e: return JSONResponse({"error": e.detail}, status_code=e.status_code) @app.post("/api/keys") async def api_create_key(request: Request): identity = get_user_identity(request) if not identity: return JSONResponse({"error": "unauthenticated"}, status_code=401) body = await request.json() name = (body.get("name") or "").strip() if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", name): return JSONResponse({"error": "Invalid key name"}, status_code=400) try: return await broker_post(identity, "/broker/keys", {"name": name}) except HTTPException as e: return JSONResponse({"error": e.detail}, status_code=e.status_code) @app.delete("/api/keys/{name}") async def api_revoke_key(name: str, request: Request): identity = get_user_identity(request) if not identity: return JSONResponse({"error": "unauthenticated"}, status_code=401) try: return await broker_delete(identity, f"/broker/keys/{name}") except HTTPException as e: return JSONResponse({"error": e.detail}, status_code=e.status_code) # --------------------------------------------------------------------------- # UI (brand-matched, self-contained, no external requests) # --------------------------------------------------------------------------- def _esc(s: str) -> str: return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) def render_page(identity: str) -> str: return PAGE_HTML.replace("@@IDENTITY@@", _esc(identity)) PAGE_HTML = """@@IDENTITY@@Keys let you use the co-op's AI from your own tools. They draw from the same balance as chat.