import random import string from datetime import datetime, timezone, timedelta from fastapi import APIRouter, HTTPException, Depends from pydantic import BaseModel from app.internal import firestore as fstore from app.internal.auth import require_firebase_token, require_service_key from app.internal.logger import logger router = APIRouter(prefix="/auth", tags=["auth"]) _CODE_TTL_MINUTES = 15 def _gen_code() -> str: return "".join(random.choices(string.ascii_uppercase + string.digits, k=6)) # --------------------------------------------------------------------------- # Web: generate a short-lived linking code # --------------------------------------------------------------------------- @router.post("/link/generate") async def generate_link_code(decoded: dict = Depends(require_firebase_token)): """Authenticated Firebase user generates a code to paste into Discord /link.""" firebase_uid = decoded["uid"] # Check if already linked existing = await fstore.doc_get("firebase_discord_links", firebase_uid) if existing and existing.get("discord_user_id"): return { "already_linked": True, "discord_user_id": existing["discord_user_id"], } code = _gen_code() expires_at = (datetime.now(timezone.utc) + timedelta(minutes=_CODE_TTL_MINUTES)).isoformat() await fstore.doc_set("link_codes", code, { "firebase_uid": firebase_uid, "expires_at": expires_at, }, merge=False) return {"code": code, "expires_minutes": _CODE_TTL_MINUTES} # --------------------------------------------------------------------------- # Discord bot: resolve a code and store the link # --------------------------------------------------------------------------- class LinkResolveBody(BaseModel): code: str discord_user_id: str discord_username: str = "" @router.post("/link") async def resolve_link_code(body: LinkResolveBody, _: dict = Depends(require_service_key)): """Discord bot resolves a linking code and permanently links the accounts.""" doc = await fstore.doc_get("link_codes", body.code.upper().strip()) if not doc: raise HTTPException(404, "Invalid or expired code.") expires_at = datetime.fromisoformat(doc["expires_at"]) if datetime.now(timezone.utc) > expires_at: await fstore.doc_delete("link_codes", body.code) raise HTTPException(410, "Code has expired. Generate a new one from the web app.") firebase_uid = doc["firebase_uid"] # Check if this Discord account is already linked to a different Firebase UID existing = await fstore.doc_get("discord_links", body.discord_user_id) if existing and existing.get("firebase_uid") and existing["firebase_uid"] != firebase_uid: raise HTTPException(409, "This Discord account is already linked to a different account.") now = datetime.now(timezone.utc).isoformat() # Store both directions await fstore.doc_set("discord_links", body.discord_user_id, { "firebase_uid": firebase_uid, "discord_username": body.discord_username, "linked_at": now, }, merge=False) await fstore.doc_set("firebase_discord_links", firebase_uid, { "discord_user_id": body.discord_user_id, "discord_username": body.discord_username, "linked_at": now, }, merge=False) # Clean up the code await fstore.doc_delete("link_codes", body.code) logger.info(f"Linked firebase_uid={firebase_uid} <-> discord_user_id={body.discord_user_id}") return {"ok": True, "firebase_uid": firebase_uid} # --------------------------------------------------------------------------- # Web: check current link status # --------------------------------------------------------------------------- @router.get("/link/status") async def link_status(decoded: dict = Depends(require_firebase_token)): firebase_uid = decoded["uid"] link = await fstore.doc_get("firebase_discord_links", firebase_uid) if link and link.get("discord_user_id"): return { "linked": True, "discord_user_id": link["discord_user_id"], "discord_username": link.get("discord_username", ""), "linked_at": link.get("linked_at"), } return {"linked": False} # --------------------------------------------------------------------------- # Web: unlink # --------------------------------------------------------------------------- @router.delete("/link") async def unlink(decoded: dict = Depends(require_firebase_token)): firebase_uid = decoded["uid"] link = await fstore.doc_get("firebase_discord_links", firebase_uid) if not link or not link.get("discord_user_id"): raise HTTPException(404, "No linked Discord account.") discord_user_id = link["discord_user_id"] await fstore.doc_delete("discord_links", discord_user_id) await fstore.doc_delete("firebase_discord_links", firebase_uid) return {"ok": True}