129 lines
4.8 KiB
Python
129 lines
4.8 KiB
Python
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}
|