2026-07-21 22:48:59 +00:00
|
|
|
"""SSR frontend service: server-rendered content pages (content.py), the
|
|
|
|
|
interactive tool's SPA shells, and every static asset. All climate data comes
|
|
|
|
|
from the backend's content API over HTTP (api_client.py) -- this process
|
|
|
|
|
holds no data of its own and does no polars/DB work.
|
|
|
|
|
|
|
|
|
|
Repo-split Stage 7a: frontend now owns static-asset + SPA-shell serving too
|
|
|
|
|
(moved from backend/web/app.py, which used to read them from a sibling
|
|
|
|
|
frontend/ directory -- a relationship that won't exist once backend and
|
|
|
|
|
frontend are separate repos). Backend keeps a single catch-all proxy route as
|
|
|
|
|
its Caddy-less fallback, so every real topology is unaffected; only which
|
|
|
|
|
process actually reads the files off disk changed.
|
2026-07-21 17:48:55 +00:00
|
|
|
"""
|
2026-07-21 22:48:59 +00:00
|
|
|
import hashlib
|
|
|
|
|
import mimetypes
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
from fastapi import FastAPI, Request, Response
|
|
|
|
|
from fastapi.staticfiles import StaticFiles
|
2026-07-21 17:48:55 +00:00
|
|
|
|
|
|
|
|
import content
|
|
|
|
|
|
2026-07-23 04:41:43 +00:00
|
|
|
# Static assets aren't content-hashed in their filenames, so this has to stay
|
|
|
|
|
# short -- it's a revalidation window, not a long-lived immutable cache. Still
|
|
|
|
|
# saves a full body re-transfer (the mount's weak ETag otherwise forces a
|
|
|
|
|
# conditional GET on every navigation) for anything fetched again within it.
|
|
|
|
|
_STATIC_CACHE_CONTROL = "public, max-age=300"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _CachedStaticFiles(StaticFiles):
|
|
|
|
|
"""StaticFiles with a Cache-Control header stamped onto every response,
|
|
|
|
|
including 304s -- see _STATIC_CACHE_CONTROL above."""
|
|
|
|
|
|
|
|
|
|
def file_response(self, *args, **kwargs) -> Response:
|
|
|
|
|
response = super().file_response(*args, **kwargs)
|
|
|
|
|
response.headers["Cache-Control"] = _STATIC_CACHE_CONTROL
|
|
|
|
|
return response
|
|
|
|
|
|
2026-07-21 22:48:59 +00:00
|
|
|
# content's own `paths` (not a fresh `import paths` here) -- in the test
|
|
|
|
|
# environment, conftest.py's sys.modules swap-then-restore trick means a
|
|
|
|
|
# top-level `import paths` in THIS module, executed later than content.py's,
|
|
|
|
|
# would bind to backend's paths module instead of frontend's own (they share
|
|
|
|
|
# the bare name "paths"). content.paths was bound correctly during the swap
|
|
|
|
|
# window and stays that way regardless of what sys.modules["paths"] points to
|
|
|
|
|
# later -- reusing it sidesteps the timing issue entirely, in tests and prod
|
|
|
|
|
# alike.
|
|
|
|
|
paths = content.paths
|
|
|
|
|
|
2026-07-21 17:48:55 +00:00
|
|
|
app = FastAPI(title="Thermograph SSR", version="0.1.0")
|
|
|
|
|
|
2026-07-21 22:48:59 +00:00
|
|
|
# The PWA manifest is served as a static file; register its media type so
|
|
|
|
|
# StaticFiles labels it correctly (not in Python's default mimetypes map).
|
|
|
|
|
mimetypes.add_type("application/manifest+json", ".webmanifest")
|
|
|
|
|
|
2026-07-21 17:48:55 +00:00
|
|
|
|
|
|
|
|
@app.get("/healthz")
|
|
|
|
|
def healthz():
|
|
|
|
|
"""Liveness probe: the process is up and serving. Deliberately does no I/O
|
|
|
|
|
-- no call to the backend -- so it stays cheap and reliable for a tight
|
|
|
|
|
healthcheck interval. Readiness (backend reachable) is a separate concern."""
|
|
|
|
|
return {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 22:48:59 +00:00
|
|
|
def _not_modified(request: Request, etag: str) -> bool:
|
|
|
|
|
inm = request.headers.get("if-none-match")
|
|
|
|
|
return inm is not None and inm == etag
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _page(name):
|
|
|
|
|
"""Route handler for one SPA-shell HTML page (the interactive tool's
|
|
|
|
|
calendar/day/score/compare/legend/alerts views). Serves the file with its
|
|
|
|
|
__ORIGIN__ placeholder (the link-preview/Open Graph tags) filled in --
|
|
|
|
|
preview crawlers need absolute URLs, and the host differs between LAN and
|
|
|
|
|
prod. Uses content._origin (not a simpler duplicate) since this route is
|
|
|
|
|
reached both directly (Caddy) and through backend's proxy fallback, and
|
|
|
|
|
only that version prefers X-Forwarded-Host over Host (repo-split Stage 5)
|
|
|
|
|
-- required for the proxied case to resolve the real browser-facing host
|
2026-07-23 04:41:43 +00:00
|
|
|
instead of this internal hop's own address.
|
|
|
|
|
|
|
|
|
|
The shell file itself (and the verification <meta> tags, also constant
|
|
|
|
|
for the process's lifetime) is read and prepped once, not on every
|
|
|
|
|
request -- only the __ORIGIN__ substitution actually varies per request,
|
|
|
|
|
and even that repeats across requests (one canonical origin in the
|
|
|
|
|
common topology), so the substituted HTML + its ETag are memoized per
|
|
|
|
|
origin instead of re-read-and-re-sha1'd every time."""
|
2026-07-21 22:48:59 +00:00
|
|
|
path = os.path.join(paths.STATIC_DIR, name)
|
2026-07-23 04:41:43 +00:00
|
|
|
_template: list[str] = [] # lazy singleton cell, populated on first request
|
|
|
|
|
_by_origin: dict[str, tuple[str, str]] = {} # origin_prefix -> (html, etag)
|
|
|
|
|
|
|
|
|
|
def _load_template() -> str:
|
|
|
|
|
if not _template:
|
|
|
|
|
with open(path, encoding="utf-8") as f:
|
|
|
|
|
html = f.read()
|
|
|
|
|
# Search-engine verification <meta> tags (same source as the SSR
|
|
|
|
|
# content pages), so the interactive tool's own pages carry them
|
|
|
|
|
# too. Also constant for the process's life -- folded in here.
|
|
|
|
|
verify = content.head_verify_html()
|
|
|
|
|
if verify:
|
|
|
|
|
html = html.replace("<head>", f"<head>\n {verify}", 1)
|
UI product-event instrumentation (design + flagged-off prototype)
Extends the existing /api/v2/event beacon into a small, typed, durable event
schema so the interactive views can answer product questions the request log
cannot: whether a visitor ever gets a location, whether search finds anything,
which controls and views earn their maintenance, and which dead ends people
actually hit.
Seven events with three enum dimension slots, stored as hourly aggregates in a
TimescaleDB hypertable plus one JSONL line per event for the 30-day Loki view.
No row per interaction and no column an identifier could go in: no cookie, no
session id, no user id, no IP, no coordinates, no free text, no URLs. The IP is
a per-minute rate-limit key and nothing else.
Abuse resistance for a public endpoint on a 60%-crawler site: closed allowlist
(unknown names collapse to one bucket), 4 KB streaming body cap, per-IP ceiling
raised to 120/min (30 was sized for four coarse events and would have silently
truncated a batched stream), soft Sec-Fetch-Site first-party check, uniform 204.
Ships inert -- THERMOGRAPH_EVENTS gates both the recorder and the flag stamp on
<html> that makes the client send anything, and is unset everywhere. See
UI-EVENTS.md for the schema, the rejected transport/storage alternatives, and
the privacy decisions that must be settled before the flag is turned on.
2026-07-23 22:49:55 +00:00
|
|
|
# Same flag stamp the SSR base template applies: track.js sends
|
|
|
|
|
# nothing unless it finds it. Folded into the memoized template
|
|
|
|
|
# because it is constant for the process's life.
|
|
|
|
|
if content.EVENTS_ENABLED:
|
|
|
|
|
html = html.replace('<html lang="en">',
|
|
|
|
|
'<html lang="en" data-tg-events="1">', 1)
|
2026-07-23 04:41:43 +00:00
|
|
|
_template.append(html)
|
|
|
|
|
return _template[0]
|
2026-07-21 22:48:59 +00:00
|
|
|
|
|
|
|
|
def route(request: Request):
|
2026-07-23 04:41:43 +00:00
|
|
|
origin_prefix = f"{content._origin(request)}{content.BASE}"
|
|
|
|
|
cached = _by_origin.get(origin_prefix)
|
|
|
|
|
if cached is None:
|
|
|
|
|
html = _load_template().replace("__ORIGIN__", origin_prefix)
|
|
|
|
|
etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"'
|
|
|
|
|
cached = (html, etag)
|
|
|
|
|
_by_origin[origin_prefix] = cached
|
|
|
|
|
html, etag = cached
|
2026-07-21 22:48:59 +00:00
|
|
|
if _not_modified(request, etag):
|
|
|
|
|
return Response(status_code=304, headers={"ETag": etag})
|
|
|
|
|
return Response(html, media_type="text/html", headers={"ETag": etag})
|
|
|
|
|
return route
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 17:48:55 +00:00
|
|
|
# NOTE: "/" is not here -- the homepage is server-rendered by content.register().
|
|
|
|
|
content.register(app)
|
2026-07-21 22:48:59 +00:00
|
|
|
|
|
|
|
|
app.add_api_route(f"{content.BASE}/calendar", _page("calendar.html"), methods=["GET", "HEAD"], include_in_schema=False)
|
|
|
|
|
app.add_api_route(f"{content.BASE}/day", _page("day.html"), methods=["GET", "HEAD"], include_in_schema=False)
|
|
|
|
|
app.add_api_route(f"{content.BASE}/score", _page("score.html"), methods=["GET", "HEAD"], include_in_schema=False)
|
|
|
|
|
app.add_api_route(f"{content.BASE}/compare", _page("compare.html"), methods=["GET", "HEAD"], include_in_schema=False)
|
|
|
|
|
app.add_api_route(f"{content.BASE}/legend", _page("legend.html"), methods=["GET", "HEAD"], include_in_schema=False)
|
|
|
|
|
app.add_api_route(f"{content.BASE}/alerts", _page("subscriptions.html"), methods=["GET", "HEAD"], include_in_schema=False)
|
|
|
|
|
|
|
|
|
|
# Everything else under BASE (app.js, style.css, nav.js, manifest, favicons, …)
|
|
|
|
|
# is a static asset. Registered last so the explicit routes above win. Mounts
|
|
|
|
|
# must start with "/", so at the root (BASE == "") mount at "/" -- the
|
|
|
|
|
# earlier routes still win because Starlette matches in registration order.
|
2026-07-23 04:41:43 +00:00
|
|
|
app.mount(content.BASE or "/", _CachedStaticFiles(directory=paths.STATIC_DIR), name="static")
|