open-navigator / api /error_pages.py
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
6.69 kB
"""
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'<p class="suggestion">{safe_suggestion}</p>' if safe_suggestion else ""
)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{status_code} · {safe_title} · CommunityOne</title>
<style>
:root {{ color-scheme: light dark; }}
* {{ box-sizing: border-box; }}
body {{
margin: 0; min-height: 100vh; display: flex; align-items: center;
justify-content: center; padding: 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #0f172a; color: #e2e8f0;
}}
.card {{
max-width: 540px; width: 100%; background: #1e293b; border: 1px solid #334155;
border-radius: 16px; padding: 40px; box-shadow: 0 10px 40px rgba(0,0,0,.35);
}}
.code {{ font-size: 13px; letter-spacing: .12em; text-transform: uppercase; color: #64748b; }}
h1 {{ margin: 8px 0 12px; font-size: 26px; line-height: 1.2; color: #f8fafc; }}
p {{ margin: 0 0 16px; line-height: 1.6; color: #cbd5e1; }}
.suggestion {{ color: #94a3b8; font-size: 15px; }}
.actions {{ display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; }}
a.btn {{
display: inline-block; padding: 11px 18px; border-radius: 10px;
text-decoration: none; font-weight: 600; font-size: 15px;
}}
a.primary {{ background: #6366f1; color: #fff; }}
a.primary:hover {{ background: #4f46e5; }}
a.ghost {{ background: transparent; color: #cbd5e1; border: 1px solid #475569; }}
a.ghost:hover {{ border-color: #94a3b8; color: #f1f5f9; }}
.path {{ margin-top: 24px; font-size: 12px; color: #64748b; word-break: break-all; }}
</style>
</head>
<body>
<main class="card">
<div class="code">Error {status_code}</div>
<h1>{safe_title}</h1>
<p>{safe_message}</p>
{suggestion_html}
<div class="actions">
<a class="btn primary" href="{escape(report_url)}">Report this issue</a>
<a class="btn ghost" href="{escape(mailto)}">Email support</a>
<a class="btn ghost" href="{escape(home_url)}">Back to CommunityOne</a>
</div>
<div class="path">{escape(path)}</div>
</main>
</body>
</html>"""
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)