From c51c60bd94a942d2a18ab0291dd7998af621a9ae Mon Sep 17 00:00:00 2001 From: emi Date: Tue, 21 Jul 2026 20:52:11 +0000 Subject: [PATCH] Configurable API base + CORS for the frontend/backend split (Stage 5) (#18) --- content.py | 25 +++++++++++++++++++++++-- static/account.js | 37 +++++++++++++++++++++++-------------- static/cache.js | 15 ++++++++++++--- static/compare.js | 4 ++-- static/digest.js | 11 +++++++++-- static/mappicker.js | 5 ++++- static/subscriptions.js | 2 +- templates/base.html.j2 | 38 +++++++++++++++++++------------------- templates/city.html.j2 | 2 +- templates/home.html.j2 | 18 +++++++++--------- 10 files changed, 103 insertions(+), 54 deletions(-) diff --git a/content.py b/content.py index e1b5ce1..207d678 100644 --- a/content.py +++ b/content.py @@ -27,6 +27,14 @@ import paths _BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/") BASE = f"/{_BASE}" if _BASE else "" +# Backend's browser-facing origin+base (e.g. "http://192.168.1.5:8137/thermograph"), +# for templates referencing backend-owned things -- the interactive SPA pages +# (_page()-served, never by this service), /digest, and every static JS/CSS/ +# favicon/manifest file (this service mounts no static files of its own; see +# app.py). Empty by default: today's exact same-origin behavior, where those +# references stay relative (ASSET_BASE == BASE). Repo-split Stage 5. +ASSET_BASE = os.environ.get("THERMOGRAPH_API_BASE_PUBLIC", "").rstrip("/") or BASE + _env = Environment( loader=FileSystemLoader(paths.TEMPLATES_DIR), autoescape=select_autoescape(["html", "xml", "j2"]), @@ -42,15 +50,28 @@ _env.filters["ordinal"] = fmt.pct_ordinal # --- helpers ------------------------------------------------------------- def _origin(request: Request) -> str: + # x-forwarded-host takes precedence over host: when reached via backend's + # internal proxy fallback (no Caddy in front -- see _proxy_to_frontend in + # backend/web/app.py), Host is the internal hop's own address, not the + # browser-facing one. proto = request.headers.get("x-forwarded-proto") or request.url.scheme - host = request.headers.get("host") or request.url.netloc + host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc return f"{proto}://{host}" def _respond_html(request: Request, template: str, **ctx) -> Response: o = _origin(request) + # og:image (an Open Graph tag, which the spec requires be absolute) is the + # one place a *static asset* reference needs an absolute URL always, not + # just when THERMOGRAPH_API_BASE_PUBLIC happens to be set -- ASSET_BASE is + # already absolute in that case, otherwise it's the same relative BASE + # base_url itself is built from, so prefixing with this request's own + # origin recovers exactly today's behavior in the default topology. + asset_base_url = ASSET_BASE if ASSET_BASE.startswith(("http://", "https://")) else f"{o}{ASSET_BASE}" ctx.setdefault("unit_default", fmt.current_unit()) - html = _env.get_template(template).render(base=BASE, origin=o, base_url=f"{o}{BASE}", **ctx) + html = _env.get_template(template).render( + base=BASE, asset_base=ASSET_BASE, asset_base_url=asset_base_url, + origin=o, base_url=f"{o}{BASE}", **ctx) etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"' inm = request.headers.get("if-none-match") if inm and etag in {t.strip() for t in inm.split(",")}: diff --git a/static/account.js b/static/account.js index 80efaa9..ce11947 100644 --- a/static/account.js +++ b/static/account.js @@ -1,28 +1,37 @@ // Accounts: the header "Sign in / account" entry, the auth modal, and the shared -// helpers other modules (subscriptions.js) use to talk to authed endpoints. +// helpers other modules (cache.js, mappicker.js, subscriptions.js, compare.js, +// digest.js) use to talk to backend — API calls and page/asset links to +// backend-owned paths (the interactive tool pages, /alerts, /digest, …) alike. // -// Auth is a same-origin HttpOnly cookie, so the frontend stores no token — it just -// sends `credentials: same-origin` and asks `GET users/me` who it is. Loaded on -// every page the way units.js is (imported by each page's entry module). +// Auth is an HttpOnly cookie; credentials: "include" (not "same-origin") so it +// still rides along when this script itself is loaded cross-origin (repo-split +// Stage 5 — a frontend SSR service on a different origin than backend, proven +// via a genuinely cross-origin LAN-dev run). Loaded on every page the way +// units.js is (imported by each page's entry module). let currentUser = null; // {id, email, display_name} or null let discordEnabled = false; // is Discord linking configured on the server? let discordChecked = false; // have we asked yet? (once per page load) const authCbs = []; // notified on login/logout so pages can re-gate -// The app's base path (e.g. "/thermograph"), derived from this module's own URL -// — it's always served from `${base}/account.js`, so this resolves correctly on -// both the depth-1 interactive pages and the deep SEO URLs (/climate/{slug}/…), -// where a directory-relative "api/v2/…" would otherwise resolve wrong and 404. -const APP_BASE = new URL(".", import.meta.url).pathname.replace(/\/$/, ""); -const u = (path) => `${APP_BASE}/${String(path).replace(/^\//, "")}`; +// backend's own origin+base (e.g. "https://thermograph.org/thermograph", or +// "http://192.168.1.5:8137/thermograph" when frontend and backend are on +// genuinely different origins) — derived from this module's own URL, since +// account.js (like every other frontend/*.js file) is always served BY +// backend, never by the frontend SSR service. Keeping the full origin (not +// just the path, as this used to) is what makes fetch()/href targets built +// from it resolve to backend even when this script was loaded from a page on +// a different origin (a frontend_ssr-rendered page in the cross-origin case). +export const APP_BASE = new URL(".", import.meta.url).href.replace(/\/$/, ""); +export const u = (path) => `${APP_BASE}/${String(path).replace(/^\//, "")}`; // --- shared fetch helper ----------------------------------------------------- -// Absolute app-base URL (so it works from any page depth), cookie sent, and a -// constant X-TG-Auth header the server can require as cheap CSRF defense (trivial -// same-origin, impossible cross-site without a preflight we never grant). +// Absolute app-base URL (so it works from any page depth, and from any origin +// once split), cookie sent, and a constant X-TG-Auth header the server can +// require as cheap CSRF defense (still meaningful cross-origin: forging it +// needs a preflight-clearing request we never grant, same as same-origin). export async function apiFetch(path, { method = "GET", json, form } = {}) { - const opts = { method, credentials: "same-origin", headers: { "X-TG-Auth": "1" } }; + const opts = { method, credentials: "include", headers: { "X-TG-Auth": "1" } }; if (json !== undefined) { opts.headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(json); diff --git a/static/cache.js b/static/cache.js index d55aa42..b4262e9 100644 --- a/static/cache.js +++ b/static/cache.js @@ -4,6 +4,13 @@ // multi-year calendar payloads persist across tabs and restarts — with a small // in-memory map in front for same-page rereads. // +// URLs are resolved via account.js's `u()` (backend's own origin+base) rather +// than a bare relative fetch() — account.js is always served by backend, so +// this keeps every request correctly targeted at backend even when this +// script is loaded from a frontend_ssr-rendered page on a different origin +// (repo-split Stage 5). credentials: "include" (not the fetch default) is +// what makes the auth cookie ride along in that same case. +// // Freshness is layered: // * fresh (within TTL, stored today) → served as-is, no request. // * stale + caller passed onUpdate → served instantly, then revalidated in @@ -13,6 +20,8 @@ // etag is stored, so an unchanged payload costs an empty 304). // * network failure with any entry cached → the stale copy, rather than an error. +import { u } from "./account.js"; + export const TTL = { grade: 600000, forecast: 1800000, calendar: 21600000, day: 600000, score: 21600000 }; // calendar/score: 6h const IDB_NAME = "thermograph-cache", IDB_STORE = "resp"; @@ -92,7 +101,7 @@ const isFresh = (rec, ttlMs) => !!(rec && rec.day === localDay() && Date.now() - // entry (fresh again, no payload moved); a 200 stores payload + etag. async function fetchStore(url, rec) { const headers = rec && rec.etag ? { "If-None-Match": rec.etag } : {}; - const res = await fetch(url, { headers }); + const res = await fetch(u(url), { headers, credentials: "include" }); if (res.status === 304 && rec) { cachePut({ ...rec, t: Date.now(), day: localDay() }); return rec.d; @@ -206,8 +215,8 @@ export function prefetchViews(lat, lon, ownViews) { const ek = "tg-cell-etag:" + q; let prev = null; try { prev = sessionStorage.getItem(ek); } catch (e) {} - const res = await fetch(`api/v2/cell?${q}&neighbors=1`, - prev ? { headers: { "If-None-Match": prev } } : {}); + const res = await fetch(u(`api/v2/cell?${q}&neighbors=1`), + { credentials: "include", ...(prev ? { headers: { "If-None-Match": prev } } : {}) }); if (res.ok && res.status !== 304) { const bundle = await res.json(); try { sessionStorage.setItem(ek, res.headers.get("ETag") || ""); } catch (e) {} diff --git a/static/compare.js b/static/compare.js index 3e415db..cd86687 100644 --- a/static/compare.js +++ b/static/compare.js @@ -25,7 +25,7 @@ import { MONTHS, pad, monthStart, monthEnd, buildChunks, allMonths, monthsToMask, maskToMonths, namePrimary, nameParts } from "./shared.js"; import { fmtTemp, fmtDelta, onUnitChange } from "./units.js"; -import "./account.js"; // header sign-in entry + notification bell +import { u } from "./account.js"; // header sign-in entry + notification bell, and the shared URL resolver import { initFilterSheet } from "./filtersheet.js"; const CMP = { @@ -262,7 +262,7 @@ function addLocation(lat, lon) { // the name too, so a failure here is harmless. async function resolveName(loc) { try { - const res = await fetch(`api/v2/place?lat=${loc.lat}&lon=${loc.lon}`); + const res = await fetch(u(`api/v2/place?lat=${loc.lat}&lon=${loc.lon}`), { credentials: "include" }); if (!res.ok) return; const d = await res.json(); // Skip if the location was removed meanwhile, or already got named by a load. diff --git a/static/digest.js b/static/digest.js index a34b060..50913ef 100644 --- a/static/digest.js +++ b/static/digest.js @@ -4,16 +4,23 @@ // this file blocked, and the beacons are fire-and-forget with no UI depending // on them. -const BASE = new URL("./", import.meta.url).pathname.replace(/\/$/, ""); +// Backend's own origin+base, shared with account.js rather than a second +// independent import.meta.url-derived copy (repo-split Stage 5 — this file, +// like account.js, is always served by backend, possibly from a page on a +// different origin once frontend/backend genuinely split). +import { APP_BASE } from "./account.js"; /** Report one product event. Aggregate-only: the server takes the referrer from * the request itself, so nothing identifying is sent from here. */ export function track(event) { try { - const url = `${BASE}/api/v2/event`; + const url = `${APP_BASE}/api/v2/event`; const body = JSON.stringify({ event }); // sendBeacon survives the page unloading, which a fetch() started on a link // click usually does not — that's the whole point for nav/records clicks. + // Spec limitation: sendBeacon has no credentials/header control at all, so + // it can never carry the auth cookie cross-origin — fine here, event + // tracking is anonymous and needs no auth either way. if (navigator.sendBeacon) { navigator.sendBeacon(url, new Blob([body], { type: "application/json" })); } else { diff --git a/static/mappicker.js b/static/mappicker.js index d1de4b1..24410f8 100644 --- a/static/mappicker.js +++ b/static/mappicker.js @@ -7,6 +7,8 @@ // Leaflet stays a classic script (global L), loaded before the page modules. The // map itself is created lazily the first time the picker opens (and reused after), // so pages that never open it pay nothing. +import { u } from "./account.js"; + let overlay, mapEl, map, marker, pin = null, onPickCb = null, pinIcon = null; let searchForm, searchInput, sugEl, confirmBtn, hintEl; let sugSeq = 0, sugTimer = 0, sugAbort = null; @@ -56,7 +58,8 @@ function build() { if (sugAbort) sugAbort.abort(); sugAbort = new AbortController(); try { - const res = await fetch(`api/v2/suggest?q=${encodeURIComponent(q)}`, { signal: sugAbort.signal }); + const res = await fetch(u(`api/v2/suggest?q=${encodeURIComponent(q)}`), + { signal: sugAbort.signal, credentials: "include" }); const d = await res.json(); if (seq === sugSeq) renderSug(d.results || []); } catch (err) { /* aborted, or offline — the map stays the fallback way to pick */ } diff --git a/static/subscriptions.js b/static/subscriptions.js index 199772c..c6e332a 100644 --- a/static/subscriptions.js +++ b/static/subscriptions.js @@ -238,7 +238,7 @@ function startAdd() { openPicker(null, async (lat, lon) => { let label = null; try { - const r = await fetch(`api/v2/place?lat=${lat}&lon=${lon}`); + const r = await apiFetch(`api/v2/place?lat=${lat}&lon=${lon}`); const d = await r.json(); label = d.place || null; } catch (e) { /* label optional; server falls back to cached/coords */ } diff --git a/templates/base.html.j2 b/templates/base.html.j2 index c5ed3b4..f8d825c 100644 --- a/templates/base.html.j2 +++ b/templates/base.html.j2 @@ -19,19 +19,19 @@ - + - - - - - - + + + + + + {% block head_extra %}{% endblock %} - + {% block jsonld %}{% endblock %} @@ -54,12 +54,12 @@ Inert on the SEO pages, which never load nav.js. #} @@ -75,7 +75,7 @@ homepage-only surface: every page is a possible entry point. Hidden for now; restore by uncommenting the form block below. #} {# -
@@ -103,11 +103,11 @@ {% block body_scripts %} - - - + + + {% endblock %} - - + + diff --git a/templates/city.html.j2 b/templates/city.html.j2 index 969ae93..b189d13 100644 --- a/templates/city.html.j2 +++ b/templates/city.html.j2 @@ -106,6 +106,6 @@

Percentiles compare each day to {{ display }}'s own history, so grades are relative to this location: a “Near Record” day here isn't the same temperature as elsewhere. - How the grades work →

+ How the grades work →

{% endblock %} diff --git a/templates/home.html.j2 b/templates/home.html.j2 index 7d7f88e..0551ca5 100644 --- a/templates/home.html.j2 +++ b/templates/home.html.j2 @@ -82,7 +82,7 @@ - What do the grades mean? → + What do the grades mean? →
@@ -112,7 +112,7 @@