Serve the app on thermograph.org at root; redirect emigriffith.dev/thermograph (#91)

Move Thermograph to its own domain. thermograph.org now serves the app at its
root, and emigriffith.dev/thermograph* permanently redirects there (prefix
stripped, so deep links map straight across).

- app.py: BASE now supports an empty root prefix. THERMOGRAPH_BASE=/ (or empty)
  yields BASE="" so routes sit at "/", "/calendar", "/api/v2/…" with no double
  slashes; the bare-base redirect is skipped and the static mount falls back to
  "/". Non-empty values keep the existing "/thermograph" sub-path behavior
  unchanged (backward compatible).
- deploy/Caddyfile: add a thermograph.org site block reverse-proxying to the
  uvicorn on 127.0.0.1:8137; turn emigriffith.dev's /thermograph handler into a
  permanent redirect to thermograph.org (handle_path strips the prefix; the bare
  /thermograph goes to the root).
- deploy/thermograph.env.example: default THERMOGRAPH_BASE=/ (app owns the domain).
- DEPLOY.md: document the two-domain layout and that deploy.sh does not ship the
  Caddyfile or env — those are applied on the VPS by hand.

The frontend already uses base-relative URLs, so it follows the root base with no
changes. Verified both modes locally (root serves /, /calendar, /api/v2/*; the
/thermograph sub-path still redirects bare→slash and 404s at root) and the full
test suite (123) passes.
This commit is contained in:
Emi Griffith 2026-07-15 12:58:32 -07:00 committed by GitHub
parent c3bacdce0c
commit d58b732480

21
app.py
View file

@ -32,7 +32,13 @@ FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend")
# live at https://<host>/thermograph/ behind a shared domain. Override with the # live at https://<host>/thermograph/ behind a shared domain. Override with the
# THERMOGRAPH_BASE env var. The frontend uses base-relative URLs, so it follows # THERMOGRAPH_BASE env var. The frontend uses base-relative URLs, so it follows
# this automatically without hardcoding the prefix. # this automatically without hardcoding the prefix.
BASE = "/" + os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/") #
# THERMOGRAPH_BASE=/ (or empty) means the app owns the whole domain: BASE becomes
# "" so every route sits at the domain root ("/", "/calendar", "/api/v2/…") with
# no prefix and no leading double slash. A non-empty value keeps the sub-path form
# ("/thermograph").
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
BASE = f"/{_BASE}" if _BASE else ""
def _weather_fetch_error(e) -> HTTPException: def _weather_fetch_error(e) -> HTTPException:
@ -592,9 +598,12 @@ def _page(name):
# The un-slashed base redirects to BASE/ so the frontend's relative asset URLs # The un-slashed base redirects to BASE/ so the frontend's relative asset URLs
# resolve correctly. The app stays scoped to BASE and never claims "/", leaving # resolve correctly. Under a sub-path this keeps the app scoped to BASE (never
# the domain root free for another app (e.g. a portfolio) to own via the proxy. # claiming "/", so the domain root stays free for another app via the proxy). When
# BASE is "" the app owns the root: "/" is already the index route below, so there
# is no bare base to redirect (and "" is not a valid route path).
# Pages also answer HEAD — link-preview crawlers probe with it before fetching. # Pages also answer HEAD — link-preview crawlers probe with it before fetching.
if BASE:
app.add_api_route(BASE, lambda: RedirectResponse(url=f"{BASE}/"), methods=["GET", "HEAD"], include_in_schema=False) app.add_api_route(BASE, lambda: RedirectResponse(url=f"{BASE}/"), methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/", _page("index.html"), methods=["GET", "HEAD"], include_in_schema=False) app.add_api_route(f"{BASE}/", _page("index.html"), methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/calendar", _page("calendar.html"), methods=["GET", "HEAD"], include_in_schema=False) app.add_api_route(f"{BASE}/calendar", _page("calendar.html"), methods=["GET", "HEAD"], include_in_schema=False)
@ -604,5 +613,7 @@ app.add_api_route(f"{BASE}/legend", _page("legend.html"), methods=["GET", "HEAD"
app.add_api_route(f"{BASE}/alerts", _page("subscriptions.html"), methods=["GET", "HEAD"], include_in_schema=False) app.add_api_route(f"{BASE}/alerts", _page("subscriptions.html"), methods=["GET", "HEAD"], include_in_schema=False)
# Everything else under BASE (app.js, style.css, nav.js, …) is a static asset. # Everything else under BASE (app.js, style.css, nav.js, …) is a static asset.
# Registered last so the explicit page routes above win. # Registered last so the explicit page routes above win. Mounts must start with
app.mount(BASE, StaticFiles(directory=FRONTEND_DIR), name="static") # "/", so at the root (BASE == "") mount at "/" — the earlier routes still win
# because Starlette matches in registration order.
app.mount(BASE or "/", StaticFiles(directory=FRONTEND_DIR), name="static")