thermograph/backend/api/content_routes.py
Emi Griffith 72e141afd3 Emit https origin for the public host in canonical/og/sitemap URLs
The SSR frontend's _origin() (canonical, og:url, og:image, robots, sitemap
<loc>) and the backend content API's _origin() (the jsonld url folded into
each payload) both trusted x-forwarded-proto / request.url.scheme. Behind
Caddy — which terminates TLS and reverse-proxies plain HTTP — those read
"http", so on the HTTPS-only public site every absolute URL emitted http://,
which 308-redirects to https://: canonicals self-conflict and the sitemap
lists redirecting URLs.

Take the scheme from the configured public origin (THERMOGRAPH_BASE_URL, set
per-host in the deploy env) when the request arrives on that host, so prod and
beta emit https. localhost and LAN dev never match the public host and keep the
observed scheme, so plain-HTTP development is unchanged; the frontend keeps its
X-Forwarded-Host precedence for the proxy-fallback path.

Tests assert canonical/og:url/og:image, robots Sitemap and every sitemap <loc>
are https for the public host (and via X-Forwarded-Host), that a LAN host stays
http, and that the backend payload's jsonld url is likewise https.
2026-07-24 16:04:42 -07:00

181 lines
7.2 KiB
Python

"""SSR content JSON API — the data a frontend SSR layer needs to render the
climate hub / per-city / month / records / homepage surfaces, without importing
backend code in-process. See backend/api/content_payloads.py for the builders;
this module is just FastAPI route wiring + the same derived-store/ETag caching
pattern web/app.py's own endpoints use.
"""
import hashlib
import os
from urllib.parse import urlsplit
from fastapi import APIRouter, HTTPException, Request, Response
from api import content_payloads as payloads
from api import sitemap as sitemap_mod
from api.payloads import content_token
from data import climate
from data import cities
from data import grid
from data import store
router = APIRouter(tags=["content"])
# Same convention as web/app.py and web/content.py: the deployment's base path,
# used only for the breadcrumb hrefs and jsonld URL this endpoint returns.
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
BASE = f"/{_BASE}" if _BASE else ""
def _etag_for(kind: str, cell_id: str, key: str, token: str) -> str:
h = hashlib.sha1(f"{kind}:{cell_id}:{key}:{token}".encode()).hexdigest()[:20]
return f'W/"{h}"'
def _not_modified(request: Request, etag: str) -> bool:
inm = request.headers.get("if-none-match")
if not inm:
return False
tags = {t.strip() for t in inm.split(",")}
return "*" in tags or etag in tags or etag.removeprefix("W/") in tags
def _json_response(body: bytes, etag: str) -> Response:
return Response(content=body, media_type="application/json", headers={"ETag": etag})
def _cached(request: Request, kind: str, cell_id: str, key: str, token: str, build) -> Response:
"""Same shape as web/app.py's _cached_response: If-None-Match -> empty 304;
a token-valid store row -> replay it; otherwise build, persist, serve.
store.get_payload/put_payload both deal in already-encoded JSON bytes, not
the payload dict -- put_payload returns the exact bytes it persisted so the
caller can serve them without re-serializing."""
etag = _etag_for(kind, cell_id, key, token)
if _not_modified(request, etag):
return Response(status_code=304, headers={"ETag": etag})
body = store.get_payload(kind, cell_id, key, token)
if body is not None:
return _json_response(body, etag)
payload = build()
return _json_response(store.put_payload(kind, cell_id, key, token, payload), etag)
def _city_cell(slug: str):
"""(city, cell) for a slug, or raise 404 — both steps are cheap (a dict
lookup and a pure grid snap) and, crucially, load no history, so the
derived-store token can be computed and a cache hit served without ever
touching the ~45-year archive."""
city = cities.get(slug)
if city is None:
raise HTTPException(status_code=404, detail="Unknown city.")
return city, grid.snap(city["lat"], city["lon"])
def _load_history(cell):
"""The cell's ~45-year archive, or raise 503 while it's still warming --
the expensive load that used to run on every request. Now called only from
the content handlers' build() closures, i.e. only on a derived-store miss."""
history = climate.load_cached_history(cell)
if history is None or history.is_empty():
try:
history, _ = climate.get_history(cell)
except Exception: # noqa: BLE001
history = None
if history is None or history.is_empty():
raise HTTPException(status_code=503, detail="Climate data is warming up; please retry shortly.")
return history
# The configured public origin is the source of truth for the scheme. Caddy
# terminates TLS and reverse-proxies plain HTTP, so x-forwarded-proto (and
# request.url.scheme) read "http" even though the public site is HTTPS-only --
# and this origin is folded into the jsonld "url" the payloads carry. Trusting
# the forwarded scheme makes that url (and the canonical/og the frontend builds
# from the same origin it forwards here) emit http://, which 308-redirects to
# https://. So a request that arrives on the configured public host is answered
# with the configured public scheme; localhost/LAN dev never matches that host
# and keeps the observed scheme, leaving plain-HTTP development unaffected.
_PUBLIC = urlsplit(os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org"))
def _origin(request: Request) -> str:
host = request.headers.get("host") or request.url.netloc
if _PUBLIC.scheme and _PUBLIC.netloc and host == _PUBLIC.netloc:
proto = _PUBLIC.scheme
else:
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
return f"{proto}://{host}"
@router.get("/content/hub")
def content_hub(request: Request):
return payloads.hub_payload()
@router.get("/content/sitemap")
def content_sitemap(request: Request):
return [{"path": p, "changefreq": cf, "priority": pr} for p, cf, pr in sitemap_mod.sitemap_entries()]
@router.get("/content/indexnow-key")
def content_indexnow_key(request: Request):
import indexnow
return {"key": indexnow.key()}
@router.get("/content/home")
def content_home(request: Request):
return payloads.home_payload()
@router.get("/content/city/{slug}")
def content_city(request: Request, slug: str):
city, cell = _city_cell(slug)
token = content_token(cell["id"])
origin = _origin(request)
def build():
history = _load_history(cell)
recent = None
try:
recent = climate.get_recent_forecast(cell)
except Exception: # noqa: BLE001 - today_vs_normal degrades to None
pass
return payloads.city_payload(origin, BASE, city, history, recent)
# origin is folded into the cache key (not just slug) because the payload's
# jsonld.url is origin-qualified -- without this, whichever origin's request
# happens to populate the cache first would be served to every other origin
# too (and the ETag, derived from this same key, wouldn't vary either, so a
# different origin's client could get a false 304). A no-op in the default
# single-canonical-origin prod topology; load-bearing once something is
# genuinely served cross-origin (see Stage 5).
return _cached(request, "content-city", cell["id"], f"{slug}:{origin}", token, build)
@router.get("/content/city/{slug}/month/{month}")
def content_city_month(request: Request, slug: str, month: str):
if month not in payloads.MONTH_INDEX:
raise HTTPException(status_code=404, detail="Unknown month.")
city, cell = _city_cell(slug)
token = content_token(cell["id"])
def build():
history = _load_history(cell)
return payloads.month_payload(BASE, city, history, payloads.MONTH_INDEX[month])
return _cached(request, "content-month", cell["id"], f"{slug}:{month}", token, build)
@router.get("/content/city/{slug}/records")
def content_city_records(request: Request, slug: str):
city, cell = _city_cell(slug)
token = content_token(cell["id"])
origin = _origin(request)
def build():
history = _load_history(cell)
return payloads.records_payload(origin, BASE, city, history)
# See content_city's comment: origin folded into the cache key for the
# same jsonld.url / ETag-correctness reason.
return _cached(request, "content-records", cell["id"], f"{slug}:{origin}", token, build)