"""Captcha checks on the two forms a stranger can reach. Cap — self-hosted, running beside us as the `cap` service. Proof-of-work rather than a puzzle, which means the person signing up is not described to a third party in order to be let in. Turnstile and then hCaptcha were here before it; both told Cloudflare or IPRoyal who was at the door. Both the sign-up and the contact form used to carry their own copy of this, which is how they drifted apart. It lives here once so that the answer to "what happens when the challenge is missing, wrong, or unanswerable" is given in a single place. A blank secret means no captcha at all. That is the deliberate default: a deployment that has not stood Cap up must still be able to register its first administrator, so an unset key reads as "there is no challenge here", never as "reject everybody". """ import logging from fastapi import HTTPException from app.config import settings logger = logging.getLogger(__name__) def configured() -> bool: return bool(settings.CAP_SECRET_KEY and settings.CAP_SITE_KEY and settings.CAP_API_URL) def verify_url() -> str: """Cap namespaces every route under the site key it belongs to.""" return f"{settings.CAP_API_URL.rstrip('/')}/{settings.CAP_SITE_KEY}/siteverify" async def verify(token: str, *, fail_open: bool) -> bool: """Ask Cap whether this token is a real, unspent solve. `fail_open` decides what an unreachable Cap means, and the two callers genuinely want different answers: an outage that stops people creating accounts costs the site its users, while an outage that bounces a contact message costs the sender one retry. """ if not configured(): return True try: import httpx # Awaited rather than blocking: uvicorn runs four workers, and a # ten-second stall on a third party would otherwise hold up every # other request that worker is serving. async with httpx.AsyncClient(timeout=10) as client: # Cap speaks the siteverify shape reCAPTCHA established, but as # JSON rather than a form post. resp = await client.post( verify_url(), json={"secret": settings.CAP_SECRET_KEY, "response": token}, ) return resp.json().get("success", False) except Exception: logger.warning("Cap verification unavailable; %s", "allowing the request" if fail_open else "rejecting the request", exc_info=True) return fail_open async def require_human(token: str | None, *, fail_open: bool) -> None: """Reject the submission unless it carries a solved challenge. Silent when no secret is set, so an unconfigured site keeps working. """ if not configured(): return if not token: raise HTTPException(status_code=400, detail="Bot verification required") if not await verify(token, fail_open=fail_open): raise HTTPException(status_code=400, detail="Bot verification failed — please try again")