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.
181 lines
7.2 KiB
Python
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)
|