From 09e96a2eaf1dad74de12edab695bc6feedd10e50 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sat, 11 Jul 2026 08:59:14 -0700 Subject: [PATCH] Link previews: Open Graph tags + logo for shared URLs (#35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- app.py | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/app.py b/app.py index fc37d2a..cfded1d 100644 --- a/app.py +++ b/app.py @@ -10,7 +10,7 @@ import time import pandas as pd from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response from fastapi.middleware.gzip import GZipMiddleware -from fastapi.responses import FileResponse, RedirectResponse +from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles import audit @@ -784,18 +784,37 @@ app.include_router(v2, prefix=f"{BASE}/api/v2") # --- static frontend (served under BASE) ----------------------------------- 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 # 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. -app.add_api_route(BASE, lambda: RedirectResponse(url=f"{BASE}/"), methods=["GET"], include_in_schema=False) -app.add_api_route(f"{BASE}/", lambda: _page("index.html"), methods=["GET"], 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}/day", lambda: _page("day.html"), methods=["GET"], 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}/legend", lambda: _page("legend.html"), methods=["GET"], include_in_schema=False) +# Pages also answer HEAD — link-preview crawlers probe with it before fetching. +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}/calendar", _page("calendar.html"), methods=["GET", "HEAD"], 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}/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. # Registered last so the explicit page routes above win.