Link previews: Open Graph tags + logo for shared URLs (#35)

Sharing a Thermograph URL (Discord, Slack, iMessage…) now unfurls into a
card with the site name, page title, description, accent color, and logo.

- All five pages get description/theme-color/og:*/twitter:card meta and a
  favicon. og:url/og:image need absolute URLs and crawlers don't run JS, so
  pages carry an __ORIGIN__ placeholder the server fills in per request from
  X-Forwarded-Proto (Caddy) / scheme + the Host header + base path — correct
  on both the LAN dev server and the prod domain without hardcoding either.
- New logo assets: the header's ▚ mark as an app icon (accent quadrants on
  the dark surface) — logo.png (512x512, the og:image) and logo.svg (favicon).
- Page routes render the substitution with a weak ETag + 304 revalidation
  (replacing plain FileResponse) and now answer HEAD, which preview crawlers
  probe with (previously 404).
This commit is contained in:
Emi Griffith 2026-07-11 08:59:14 -07:00 committed by GitHub
parent f149c8fd4f
commit 09e96a2eaf

35
app.py
View file

@ -10,7 +10,7 @@ import time
import pandas as pd import pandas as pd
from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import audit import audit
@ -784,18 +784,37 @@ app.include_router(v2, prefix=f"{BASE}/api/v2")
# --- static frontend (served under BASE) ----------------------------------- # --- static frontend (served under BASE) -----------------------------------
def _page(name): def _page(name):
return FileResponse(os.path.join(FRONTEND_DIR, name)) """Route handler for one HTML page. Serves the file with its __ORIGIN__
placeholders (the link-preview/Open Graph tags) filled in as the request's
scheme://host + BASE preview crawlers (Discord, Slack, ) need absolute
URLs, and the host differs between LAN and prod. The scheme comes from
X-Forwarded-Proto when a reverse proxy (Caddy) fronts the plain-HTTP
uvicorn; the proxy passes the original Host header through untouched."""
path = os.path.join(FRONTEND_DIR, name)
def route(request: Request):
with open(path, encoding="utf-8") as f:
html = f.read()
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
host = request.headers.get("host") or request.url.netloc
html = html.replace("__ORIGIN__", f"{proto}://{host}{BASE}")
etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"'
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
# 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. The app stays scoped to BASE and never claims "/", leaving
# the domain root free for another app (e.g. a portfolio) to own via the proxy. # the domain root free for another app (e.g. a portfolio) to own via the proxy.
app.add_api_route(BASE, lambda: RedirectResponse(url=f"{BASE}/"), methods=["GET"], include_in_schema=False) # Pages also answer HEAD — link-preview crawlers probe with it before fetching.
app.add_api_route(f"{BASE}/", lambda: _page("index.html"), methods=["GET"], 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}/calendar", lambda: _page("calendar.html"), methods=["GET"], 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}/day", lambda: _page("day.html"), methods=["GET"], 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}/compare", lambda: _page("compare.html"), methods=["GET"], include_in_schema=False) app.add_api_route(f"{BASE}/day", _page("day.html"), methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/legend", lambda: _page("legend.html"), methods=["GET"], include_in_schema=False) app.add_api_route(f"{BASE}/compare", _page("compare.html"), methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/legend", _page("legend.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.