Configurable API base + CORS for the frontend/backend split (Stage 5) (#18)

This commit is contained in:
emi 2026-07-21 20:52:11 +00:00
parent 11872d7e64
commit c51c60bd94
10 changed files with 103 additions and 54 deletions

View file

@ -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(",")}:

View file

@ -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);

View file

@ -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) {}

View file

@ -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.

View file

@ -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 {

View file

@ -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 */ }

View file

@ -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 */ }

View file

@ -19,19 +19,19 @@
<meta property="og:title" content="{{ self.title() }}" />
<meta property="og:description" content="{{ self.description() }}" />
<meta property="og:url" content="{{ base_url }}{{ self.canonical_path() }}" />
<meta property="og:image" content="{{ base_url }}/logo.png?v=3" />
<meta property="og:image" content="{{ asset_base_url }}/logo.png?v=3" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta property="og:image:alt" content="Thermograph logo" />
<meta name="twitter:card" content="summary" />
<link rel="icon" href="{{ base }}/favicon.svg?v=3" type="image/svg+xml" />
<link rel="icon" href="{{ base }}/favicon-48.png?v=3" type="image/png" sizes="48x48" />
<link rel="icon" href="{{ base }}/favicon-32.png?v=3" type="image/png" sizes="32x32" />
<link rel="icon" href="{{ base }}/favicon-16.png?v=3" type="image/png" sizes="16x16" />
<link rel="apple-touch-icon" href="{{ base }}/apple-touch-icon.png?v=3" />
<link rel="manifest" href="{{ base }}/manifest.webmanifest" />
<link rel="icon" href="{{ asset_base }}/favicon.svg?v=3" type="image/svg+xml" />
<link rel="icon" href="{{ asset_base }}/favicon-48.png?v=3" type="image/png" sizes="48x48" />
<link rel="icon" href="{{ asset_base }}/favicon-32.png?v=3" type="image/png" sizes="32x32" />
<link rel="icon" href="{{ asset_base }}/favicon-16.png?v=3" type="image/png" sizes="16x16" />
<link rel="apple-touch-icon" href="{{ asset_base }}/apple-touch-icon.png?v=3" />
<link rel="manifest" href="{{ asset_base }}/manifest.webmanifest" />
{% block head_extra %}{% endblock %}
<link rel="stylesheet" href="{{ base }}/style.css" />
<link rel="stylesheet" href="{{ asset_base }}/style.css" />
{% block jsonld %}{% endblock %}
</head>
<body{% if unit_default %} data-unit-default="{{ unit_default }}"{% endif %}>
@ -54,12 +54,12 @@
Inert on the SEO pages, which never load nav.js. #}
<nav class="view-nav" aria-label="Views">
<a href="{{ base }}/" data-view="map"{% if section == 'home' %} class="active"{% endif %}>Weekly</a>
<a href="{{ base }}/calendar" data-view="calendar">Calendar</a>
<a href="{{ base }}/day" data-view="day">Day Detail</a>
<a href="{{ base }}/compare" data-view="compare">Compare</a>
<a href="{{ base }}/score" data-view="score"{% if section == 'score' %} class="active"{% endif %}>Score</a>
<a href="{{ asset_base }}/calendar" data-view="calendar">Calendar</a>
<a href="{{ asset_base }}/day" data-view="day">Day Detail</a>
<a href="{{ asset_base }}/compare" data-view="compare">Compare</a>
<a href="{{ asset_base }}/score" data-view="score"{% if section == 'score' %} class="active"{% endif %}>Score</a>
<a href="{{ base }}/climate"{% if section == 'climate' %} class="active"{% endif %}>Climate</a>
<a href="{{ base }}/alerts">Alerts</a>
<a href="{{ asset_base }}/alerts">Alerts</a>
</nav>
</div>
</details>
@ -75,7 +75,7 @@
homepage-only surface: every page is a possible entry point.
Hidden for now; restore by uncommenting the form block below. #}
{#
<form class="digest-form digest-compact" method="post" action="{{ base }}/digest"
<form class="digest-form digest-compact" method="post" action="{{ asset_base }}/digest"
data-digest aria-labelledby="footer-digest-label">
<label id="footer-digest-label" for="footer-digest-email">Your city's weather, graded — monthly</label>
<div class="digest-row">
@ -103,11 +103,11 @@
{% block body_scripts %}
<!-- Header parity with the interactive pages: the °F/°C toggle, account + bell,
and the client-side temperature converter. All self-contained modules. -->
<script type="module" src="{{ base }}/units.js"></script>
<script type="module" src="{{ base }}/account.js"></script>
<script type="module" src="{{ base }}/climate.js"></script>
<script type="module" src="{{ asset_base }}/units.js"></script>
<script type="module" src="{{ asset_base }}/account.js"></script>
<script type="module" src="{{ asset_base }}/climate.js"></script>
{% endblock %}
<script type="module" src="{{ base }}/digest.js"></script>
<script type="module" src="{{ base }}/ios-install.js"></script>
<script type="module" src="{{ asset_base }}/digest.js"></script>
<script type="module" src="{{ asset_base }}/ios-install.js"></script>
</body>
</html>

View file

@ -106,6 +106,6 @@
<p class="climate-foot muted">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.
<a href="{{ base }}/legend">How the grades work →</a></p>
<a href="{{ asset_base }}/legend">How the grades work →</a></p>
</article>
{% endblock %}

View file

@ -82,7 +82,7 @@
<input id="date-input" type="date" />
</label>
<button type="button" id="today-btn" class="today-chip" title="Reset the target date to today">Today</button>
<span class="hint"><a href="{{ base }}/legend">What do the grades mean? →</a></span>
<span class="hint"><a href="{{ asset_base }}/legend">What do the grades mean? →</a></span>
</div>
</section>
@ -112,7 +112,7 @@
<ul class="unusual-row">
{% for r in ranked %}
<li class="unusual-card" style="--cat: var(--{{ r.cls }})">
<a href="{{ base }}/day#lat={{ r.lat }}&amp;lon={{ r.lon }}"
<a href="{{ asset_base }}/day#lat={{ r.lat }}&amp;lon={{ r.lon }}"
data-event="home.records_click">
<span class="unusual-city">{{ r.display }}</span>
{# The reading itself, then what it is and what it should be —
@ -141,7 +141,7 @@
<li>The result is a percentile and a plain-language grade, from Near Record cold to Near Record hot.</li>
</ol>
<p class="muted">
<a href="{{ base }}/legend">Full methodology →</a>
<a href="{{ asset_base }}/legend">Full methodology →</a>
&middot; Data: ERA5 via Open-Meteo &middot; NASA POWER &middot; MET Norway
</p>
</section>
@ -153,7 +153,7 @@
palette, so the section reads as part of the product's own scale.
Calendar carries the ramp itself — it IS the heatmap. #}
<div class="explore-cards">
<a class="explore-card" href="{{ base }}/calendar" data-event="home.nav_calendar"
<a class="explore-card" href="{{ asset_base }}/calendar" data-event="home.nav_calendar"
style="--cat: var(--warm)">
<span class="explore-name">Calendar</span>
<span class="explore-ramp" aria-hidden="true">
@ -163,17 +163,17 @@
</span>
<span class="explore-desc">Two years of your days, each colored by how unusual it was.</span>
</a>
<a class="explore-card" href="{{ base }}/compare" data-event="home.nav_compare"
<a class="explore-card" href="{{ asset_base }}/compare" data-event="home.nav_compare"
style="--cat: var(--normal)">
<span class="explore-name">Compare</span>
<span class="explore-desc">Rank any places by comfort for a trip or a move.</span>
</a>
<a class="explore-card" href="{{ base }}/day" data-event="home.nav_day"
<a class="explore-card" href="{{ asset_base }}/day" data-event="home.nav_day"
style="--cat: var(--cool)">
<span class="explore-name">Day</span>
<span class="explore-desc">Any date since 1980, anywhere: what was normal, what happened.</span>
</a>
<a class="explore-card" href="{{ base }}/alerts" data-event="home.nav_alerts"
<a class="explore-card" href="{{ asset_base }}/alerts" data-event="home.nav_alerts"
style="--cat: var(--rec-hot)">
<span class="explore-name">Alerts</span>
<span class="explore-desc">Free percentile alerts: get pinged when your weather goes statistically weird.</span>
@ -197,9 +197,9 @@
{% block body_scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script type="module" src="{{ base }}/app.js"></script>
<script type="module" src="{{ asset_base }}/app.js"></script>
{# The records strip renders real temperatures server-side as .temp spans, so
it needs the same converter the SEO pages use or they'd stay in °F while
the toggle says °C. Both import units.js, which the module loader dedupes. #}
<script type="module" src="{{ base }}/climate.js"></script>
<script type="module" src="{{ asset_base }}/climate.js"></script>
{% endblock %}