from urllib.parse import urlparse import httpx from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from . import vault from .auth import require_admin from .db import get_db # ADMIN-ONLY (require_admin): API keys / connections. Staff tokens get 403 here. # The dash POSTs credentials in; values are encrypted at rest and NEVER returned. router = APIRouter(prefix="/settings", tags=["settings"]) # The credentials the dash knows how to collect (name → human label). KNOWN = { "woo_base_url": "WooCommerce store URL (https://…)", "woo_consumer_key": "WooCommerce REST consumer key (ck_…)", "woo_consumer_secret": "WooCommerce REST consumer secret (cs_…)", "discogs_consumer_key": "Discogs OAuth consumer key", "discogs_consumer_secret": "Discogs OAuth consumer secret", "discogs_token": "Discogs personal access token", "cloudflare_api_token": "Cloudflare API token", "dealgod_api_key": "DealGod API key (X-Api-Key)", "smtp_host": "Receipt email — SMTP host (e.g. mail.monsterrobot.party)", "smtp_port": "Receipt email — SMTP port (587 STARTTLS / 465 SSL)", "smtp_user": "Receipt email — SMTP username (e.g. shop@monsterrobot.party)", "smtp_pass": "Receipt email — SMTP password", "smtp_from": "Receipt email — From address (defaults to username)", "smtp_from_name": "Receipt email — From name (e.g. Monster Robot Records)", "square_access_token": "Square access token (EAAA…) — drives the payment terminal", "square_location_id": "Square location ID (e.g. S5D3EN3YM2AN8)", "square_environment": "Square environment — production or sandbox", "bridge_key": "WordPress bridge shared secret — the WP plugin sends this to post back orders", "google_sa_email": "Google service-account email (…@…iam.gserviceaccount.com) — reads the intake sheet", "google_sa_private_key": "Google service-account private key (PEM) — signs the Sheets API token", "google_sheet_id": "Intake Google Sheet ID (from its URL)", "google_sheet_name": "Intake sheet/tab name (e.g. Sheet1)", "google_column_map": "Intake sheet column map JSON (Google-Form positions)", "openrouter_api_key": "OpenRouter API key (sk-or-…) — powers the RecordGod AI assistant (DeepSeek/Gemini/etc.)", "openrouter_model": "OpenRouter default model (e.g. deepseek/deepseek-chat, google/gemini-2.5-flash)", } class SecretIn(BaseModel): name: str value: str @router.get("/secrets") async def list_secrets(ident=Depends(require_admin), db=Depends(get_db)): have = {s["name"]: s["updated_at"] for s in await vault.list_secret_names(db)} return {"ok": True, "fields": [ {"name": n, "label": label, "set": n in have, "updated_at": have.get(n)} for n, label in KNOWN.items() ]} @router.post("/secrets") async def save_secret(body: SecretIn, ident=Depends(require_admin), db=Depends(get_db)): if body.name not in KNOWN: raise HTTPException(400, "unknown secret name") if not body.value.strip(): raise HTTPException(400, "empty value") try: await vault.set_secret(db, body.name, body.value.strip()) except vault.SecretsUnconfigured as e: raise HTTPException(503, str(e)) return {"ok": True, "name": body.name, "set": True} @router.post("/test/{service}") async def test_connection(service: str, ident=Depends(require_admin), db=Depends(get_db)): """Validate stored credentials against the live service — so 'connected' means connected.""" async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c: if service == "discogs": tok = await vault.get_secret(db, "discogs_token") if not tok: raise HTTPException(400, "save a Discogs personal access token first") r = await c.get("https://api.discogs.com/oauth/identity", headers={"Authorization": f"Discogs token={tok}", "User-Agent": "RecordGod/0.1"}) if r.status_code == 200: return {"ok": True, "connected": True, "as": r.json().get("username")} return {"ok": True, "connected": False, "detail": f"HTTP {r.status_code}"} if service == "woo": base = await vault.get_secret(db, "woo_base_url") ck = await vault.get_secret(db, "woo_consumer_key") cs = await vault.get_secret(db, "woo_consumer_secret") if not (base and ck and cs): raise HTTPException(400, "save Woo store URL + consumer key + secret first") r = await c.get(base.rstrip("/") + "/wp-json/wc/v3/orders", params={"per_page": 1}, auth=(ck, cs)) if r.status_code == 200: return {"ok": True, "connected": True, "as": urlparse(base).netloc} return {"ok": True, "connected": False, "detail": f"HTTP {r.status_code}"} if service == "dealgod": key = await vault.get_secret(db, "dealgod_api_key") if not key: raise HTTPException(400, "save a DealGod API key first") r = await c.get("https://api.dealgod.pro/api/me", headers={"X-Api-Key": key}) if r.status_code == 200: j = r.json() return {"ok": True, "connected": True, "as": f"{j.get('user')} · {j.get('plan')}"} if r.status_code == 404: return {"ok": True, "connected": False, "detail": "DealGod /api/me not deployed yet"} return {"ok": True, "connected": False, "detail": f"HTTP {r.status_code}"} if service == "openrouter": key = await vault.get_secret(db, "openrouter_api_key") if not key: raise HTTPException(400, "save an OpenRouter API key first") r = await c.get("https://openrouter.ai/api/v1/key", headers={"Authorization": f"Bearer {key}"}) if r.status_code == 200: d = r.json().get("data", {}) used, limit = d.get("usage"), d.get("limit") return {"ok": True, "connected": True, "as": f"${used} used" + (f" / ${limit} limit" if limit else " (no limit)")} return {"ok": True, "connected": False, "detail": f"HTTP {r.status_code}"} raise HTTPException(400, "unknown service")