"""hCaptcha checks on the two forms a stranger can reach. 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 never signed up for hCaptcha 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__) VERIFY_URL = "https://api.hcaptcha.com/siteverify" def configured() -> bool: return bool(settings.HCAPTCHA_SECRET_KEY) async def verify(token: str, *, fail_open: bool) -> bool: """Ask hCaptcha whether this token is a real, unspent solve. `fail_open` decides what an unreachable hCaptcha 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: resp = await client.post( VERIFY_URL, data={"secret": settings.HCAPTCHA_SECRET_KEY, "response": token}, ) return resp.json().get("success", False) except Exception: logger.warning("hCaptcha 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")