""" Self-serve Discord server -> org linking (server-26#213 item 1). POST /discord/link-codes org owner / platform admin — mint a code GET /discord/linked-guilds org owner / platform admin — this org's servers DELETE /discord/linked-guilds/{guild_id} org owner / platform admin — unlink one POST /discord/guilds/{guild_id}/link/redeem Discord bot (service key) — /drb link code: Platform admins may pass ?org_id= to act on another org (resolve_org_scope), same as the rest of the org-scoped API. The admin-only ``PUT /discord/guilds/{id}/link`` in routers/discord.py stays for support. Every mint, link, refusal and unlink is written to audit_log. Rate limits are the in-process sliding-window limiter from internal/auth.py (single c2-core instance; not shared across replicas). """ from __future__ import annotations from typing import Optional from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import JSONResponse from pydantic import BaseModel from app.config import settings from app.internal import discord_guild_link as glink from app.internal import discord_streams as streams from app.internal import firestore as fstore from app.internal.audit import write_audit from app.internal.auth import ( _RateLimiter, describe_actor, require_org_owner_token, require_service_key, resolve_org_scope, ) from app.internal.logger import logger router = APIRouter(prefix="/discord", tags=["discord"]) # Minting: 5 codes per user per 10 min — each mint revokes the previous one, # so this only stops a runaway client. mint_limiter = _RateLimiter(max_calls=5, window_seconds=600) # Redeeming: 5 attempts per server and 10 per Discord user per 10 min. With a # 10-character code over 31 symbols and a 10-minute TTL, guessing is hopeless # at this rate even across many throwaway servers. redeem_guild_limiter = _RateLimiter(max_calls=5, window_seconds=600) redeem_user_limiter = _RateLimiter(max_calls=10, window_seconds=600) def _caller_org(decoded: dict, org_id: Optional[str]) -> str: return resolve_org_scope(decoded, org_id) @router.post("/link-codes") async def create_link_code(org_id: Optional[str] = None, decoded: dict = Depends(require_org_owner_token)): """Mint a single-use code an org owner pastes into ``/drb link code:``.""" scope = _caller_org(decoded, org_id) uid, email = describe_actor(decoded) mint_limiter.check(f"{uid}:discord_link_code") out = await glink.mint_code(scope, uid) await write_audit(uid, email, "discord.link_code.create", details={"org_id": scope, "expires_at": out["expires_at"]}) logger.info(f"Discord link code minted for org={scope!r} by uid={uid}") return {**out, "command": f"/drb link code:{out['code']}"} @router.get("/linked-guilds") async def linked_guilds(org_id: Optional[str] = None, decoded: dict = Depends(require_org_owner_token)): scope = _caller_org(decoded, org_id) return { "guilds": await glink.list_org_guilds(scope), # False while DISCORD_UNLINKED_GUILD_ORG_ID is set: unlinked servers # then still act for that org instead of seeing only /drb link + /help. "unlinked_guilds_restricted": not settings.discord_unlinked_guild_org_id, } @router.delete("/linked-guilds/{guild_id}") async def unlink_guild(guild_id: str, org_id: Optional[str] = None, decoded: dict = Depends(require_org_owner_token)): scope = _caller_org(decoded, org_id) uid, email = describe_actor(decoded) try: await glink.unlink(scope, guild_id, uid) except glink.LinkError as e: raise HTTPException(e.status, e.message) # Its radio channels were playing for this org; they go with the link. stopped = await streams.stop_guild(guild_id, "unlinked") await write_audit(uid, email, "discord.guild.unlink", details={"org_id": scope, "guild_id": guild_id, "streams_stopped": len(stopped)}) return {"ok": True, "guild_id": guild_id, "streams_stopped": len(stopped)} class RedeemBody(BaseModel): code: str discord_user_id: str guild_name: Optional[str] = None @router.post("/guilds/{guild_id}/link/redeem") async def redeem_link_code(guild_id: str, body: RedeemBody, principal: dict = Depends(require_service_key)): """The Discord bot redeems a code for the server it was typed in. The bot has already checked the member holds Manage Server.""" redeem_guild_limiter.check(f"guild:{guild_id}") redeem_user_limiter.check(f"user:{body.discord_user_id}") actor = f"discord:{body.discord_user_id}" before = await streams.resolve_guild_org(guild_id) try: result = await glink.redeem_code(body.code, guild_id, body.guild_name, body.discord_user_id) except glink.LinkError as e: await write_audit(actor, "", "discord.guild.link_refused", details={"guild_id": guild_id, "reason": e.code}) return JSONResponse(status_code=e.status, content={"detail": e.message, "code": e.code}) org_id = result["org_id"] stopped: list = [] if before and before != org_id: # The server was acting for DISCORD_UNLINKED_GUILD_ORG_ID; anything it # had playing belongs to that org, not the one it just linked to. stopped = await streams.stop_guild(guild_id, "relinked") await write_audit(actor, "", "discord.guild.link", details={ "guild_id": guild_id, "org_id": org_id, "already_linked": result["already_linked"], "code_created_by_uid": result.get("created_by_uid"), "streams_stopped": len(stopped), }) logger.info(f"Discord guild {guild_id} linked to org={org_id!r} by {actor}") org = await fstore.doc_get("organizations", org_id) return {"ok": True, "guild_id": guild_id, "org_id": org_id, "org_name": (org or {}).get("name"), "already_linked": result["already_linked"]}