Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """ | |
| PDF document proxy for inline rendering. | |
| The frontend renders meeting agenda/minutes PDFs inline (react-pdf), which fetches | |
| the file bytes client-side. Government document hosts (Legistar, Granicus, SuiteOne) | |
| rarely send CORS headers, so a direct browser fetch fails. This endpoint proxies the | |
| bytes through the API so, to the browser, the document is same-origin. | |
| SSRF guard: only URLs that already exist in public.event_meeting_document.document_url | |
| are proxied — the client cannot make the server fetch an arbitrary URL. | |
| """ | |
| from __future__ import annotations | |
| import httpx | |
| from fastapi import APIRouter, HTTPException, Query | |
| from fastapi.responses import Response | |
| from loguru import logger | |
| from opentelemetry import trace | |
| from api.routes.search_postgres import get_db_pool | |
| router = APIRouter(prefix="/document", tags=["document"]) | |
| tracer = trace.get_tracer(__name__) | |
| # Upper bound on a proxied document. Meeting agenda/minutes PDFs are well under this; | |
| # the cap stops a stray large upstream file from ballooning memory. | |
| _MAX_BYTES = 50 * 1024 * 1024 | |
| # SSRF allow-list: a URL is proxyable only if it is a document we already serve. | |
| _URL_KNOWN_SQL = """ | |
| SELECT 1 | |
| FROM event_meeting_document | |
| WHERE document_url = $1 | |
| LIMIT 1 | |
| """ | |
| async def proxy_document( | |
| url: str = Query(..., description="Document URL to proxy (must be a known meeting document)"), | |
| ): | |
| """Stream a known meeting document through the API for same-origin rendering.""" | |
| pool = await get_db_pool() | |
| async with pool.acquire() as conn: | |
| known = await conn.fetchval(_URL_KNOWN_SQL, url) | |
| if not known: | |
| # Not a document we serve — refuse rather than fetch an arbitrary URL. | |
| raise HTTPException(status_code=404, detail="Unknown document") | |
| with tracer.start_as_current_span("document.proxy") as span: | |
| span.set_attribute("document.url", url) | |
| try: | |
| async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as client: | |
| resp = await client.get(url, headers={"User-Agent": "OpenNavigator/1.0"}) | |
| except httpx.HTTPError as exc: | |
| logger.warning("Document proxy fetch failed for {}: {}", url, exc) | |
| raise HTTPException(status_code=502, detail="Failed to fetch document") from exc | |
| if resp.status_code != 200: | |
| span.set_attribute("upstream.status", resp.status_code) | |
| raise HTTPException(status_code=502, detail=f"Upstream returned {resp.status_code}") | |
| content = resp.content | |
| if len(content) > _MAX_BYTES: | |
| raise HTTPException(status_code=413, detail="Document too large to display") | |
| # Trust the upstream content-type when present; default to PDF (the common case). | |
| content_type = (resp.headers.get("content-type") or "application/pdf").split(";")[0].strip() | |
| if not content_type: | |
| content_type = "application/pdf" | |
| span.set_attribute("document.content_type", content_type) | |
| return Response( | |
| content=content, | |
| media_type=content_type, | |
| headers={ | |
| "Cache-Control": "public, max-age=3600", | |
| "Content-Disposition": "inline", | |
| }, | |
| ) | |