""" Content-negotiated error responses for the API. Browser navigations (e.g. the OAuth redirect endpoints, which the frontend reaches via ``window.location.href``) should see a styled HTML page with a clickable ``mailto:`` support link — not a wall of raw JSON. Programmatic API clients still get JSON. ``error_response`` picks the representation from the request's ``Accept`` header. """ from __future__ import annotations import os from html import escape from urllib.parse import quote from typing import Any, Optional from fastapi import Request from fastapi.responses import HTMLResponse, JSONResponse # Single source of truth for the support address — referenced by the HTML page, # the mailto link, and the JSON ``support`` field. SUPPORT_EMAIL = "support@communityone.com" def wants_html(request: Request) -> bool: """True when the client prefers HTML (a browser) over JSON. A browser sends ``Accept: text/html,...`` and ranks it ahead of JSON; ``fetch``/``httpx`` clients typically send ``*/*`` or ``application/json``. """ accept = request.headers.get("accept", "") if "text/html" not in accept: return False # If JSON is explicitly preferred over HTML, honour that (API tooling). if "application/json" in accept: return accept.index("text/html") < accept.index("application/json") return True def _frontend_base(request: Request) -> str: """Origin to resolve in-app links (``/support``, home) against. The error page can be served by the API on a *different* origin than the React app (local dev: API :8001 vs Vite :5173). A relative ``/support`` would then hit the API host and 404. When ``FRONTEND_URL`` names a different origin than this request, return it as an absolute prefix; otherwise return ``""`` so links stay relative (prod is same-origin). """ fe = (os.getenv("FRONTEND_URL") or "").rstrip("/") if not fe: return "" req_origin = f"{request.url.scheme}://{request.url.netloc}" return fe if fe != req_origin else "" def _mailto(subject: str, body: str) -> str: """Build a ``mailto:`` link to support with a prefilled subject/body.""" query = f"subject={quote(subject)}&body={quote(body)}" return f"mailto:{SUPPORT_EMAIL}?{query}" def _html_page( *, status_code: int, title: str, message: str, suggestion: Optional[str], path: str, frontend_base: str = "", ) -> str: subject = f"CommunityOne support — {status_code} on {path or 'the site'}" body = ( "Hi CommunityOne team,\n\n" f"I ran into a problem.\n\n" f"What I was doing: \n" f"Page / action: {path}\n" f"Error: {status_code} {title}\n\n" "Thanks!" ) mailto = _mailto(subject, body) # Deep-link into the in-app support form (creates a GitHub-issue ticket), # prefilled with the failing path/status via query params it reads. report_url = ( f"{frontend_base}/support?category=bug" f"&subject={quote(subject)}" f"&path={quote(path or '')}" ) home_url = f"{frontend_base}/" if frontend_base else "/" safe_title = escape(title) safe_message = escape(message) safe_suggestion = escape(suggestion) if suggestion else "" suggestion_html = ( f'

{safe_suggestion}

' if safe_suggestion else "" ) return f""" {status_code} · {safe_title} · CommunityOne
Error {status_code}

{safe_title}

{safe_message}

{suggestion_html}
Report this issue Email support Back to CommunityOne
{escape(path)}
""" def error_response( request: Request, *, status_code: int, title: str, message: str, suggestion: Optional[str] = None, extra: Optional[dict[str, Any]] = None, ) -> HTMLResponse | JSONResponse: """Return an HTML error page for browsers, JSON otherwise. ``extra`` is merged into the JSON body only (e.g. validation ``errors``, a ``login`` URL); the HTML page stays intentionally simple. """ path = request.url.path if wants_html(request): return HTMLResponse( status_code=status_code, content=_html_page( status_code=status_code, title=title, message=message, suggestion=suggestion, path=path, frontend_base=_frontend_base(request), ), ) body: dict[str, Any] = { "error": title, "message": message, "path": path, "support": SUPPORT_EMAIL, } if suggestion: body["suggestion"] = suggestion if extra: body.update(extra) return JSONResponse(status_code=status_code, content=body)