diff --git a/backend/api/content_routes.py b/backend/api/content_routes.py index cd682b1..ef01df2 100644 --- a/backend/api/content_routes.py +++ b/backend/api/content_routes.py @@ -6,6 +6,7 @@ pattern web/app.py's own endpoints use. """ import hashlib import os +from urllib.parse import urlsplit from fastapi import APIRouter, HTTPException, Request, Response @@ -84,9 +85,24 @@ def _load_history(cell): return history +# The configured public origin is the source of truth for the scheme. Caddy +# terminates TLS and reverse-proxies plain HTTP, so x-forwarded-proto (and +# request.url.scheme) read "http" even though the public site is HTTPS-only -- +# and this origin is folded into the jsonld "url" the payloads carry. Trusting +# the forwarded scheme makes that url (and the canonical/og the frontend builds +# from the same origin it forwards here) emit http://, which 308-redirects to +# https://. So a request that arrives on the configured public host is answered +# with the configured public scheme; localhost/LAN dev never matches that host +# and keeps the observed scheme, leaving plain-HTTP development unaffected. +_PUBLIC = urlsplit(os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org")) + + def _origin(request: Request) -> str: - proto = request.headers.get("x-forwarded-proto") or request.url.scheme host = request.headers.get("host") or request.url.netloc + if _PUBLIC.scheme and _PUBLIC.netloc and host == _PUBLIC.netloc: + proto = _PUBLIC.scheme + else: + proto = request.headers.get("x-forwarded-proto") or request.url.scheme return f"{proto}://{host}" diff --git a/backend/tests/api/test_content_routes.py b/backend/tests/api/test_content_routes.py index 236601a..e172b9b 100644 --- a/backend/tests/api/test_content_routes.py +++ b/backend/tests/api/test_content_routes.py @@ -118,6 +118,28 @@ def test_records_payload_shape(client): assert body["canonical_path"] == "/climate/testville/records" +# --- https origin (jsonld url) ----------------------------------------------- + +def test_public_host_jsonld_url_is_https(client): + """The public site is HTTPS-only behind Caddy, which proxies plain HTTP, so + x-forwarded-proto reads "http". A request on the configured public host + (THERMOGRAPH_BASE_URL defaults to https://thermograph.org) must still fold an + https:// origin into the payload's jsonld url; otherwise the frontend renders + an http:// canonical/JSON-LD that 308-redirects.""" + import json as _json + hdr = {"host": "thermograph.org", "x-forwarded-proto": "http"} + for path in ("city/testville", "city/testville/records"): + s = _json.dumps(client.get(f"{BASE}/{path}", headers=hdr).json()) + assert "https://thermograph.org/thermograph/climate/testville" in s + assert "http://thermograph.org" not in s + + +def test_non_public_host_jsonld_keeps_forwarded_scheme(client): + hdr = {"host": "192.168.1.10:8000", "x-forwarded-proto": "http"} + body = client.get(f"{BASE}/city/testville", headers=hdr).json() + assert body["jsonld"]["@graph"][0]["url"].startswith("http://192.168.1.10:8000/") + + # --- 404s -------------------------------------------------------------------- def test_unknown_slug_is_404(client, counts): diff --git a/frontend/content.py b/frontend/content.py index 6981b25..7295362 100644 --- a/frontend/content.py +++ b/frontend/content.py @@ -13,6 +13,7 @@ import hashlib import json import logging import os +from urllib.parse import urlsplit import httpx from fastapi import HTTPException, Request, Response @@ -56,13 +57,28 @@ _log = logging.getLogger(__name__) # --- helpers ------------------------------------------------------------- +# The configured public origin is the source of truth for the scheme. Caddy +# terminates TLS and reverse-proxies plain HTTP, so x-forwarded-proto (and +# request.url.scheme) read "http" even though the public site is HTTPS-only. +# Trusting that scheme makes every canonical / og:url / og:image / sitemap +# emit http://, which 308-redirects to https:// -- self-conflicting canonicals +# and a sitemap full of redirecting URLs. So a request that arrives on the +# configured public host is answered with the configured public scheme (https on +# prod/beta, both of which set THERMOGRAPH_BASE_URL per host); localhost/LAN dev +# never matches it and keeps the observed scheme, so plain-HTTP dev is unchanged. +_PUBLIC = urlsplit(os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org")) + + 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("x-forwarded-host") or request.headers.get("host") or request.url.netloc + if _PUBLIC.scheme and _PUBLIC.netloc and host == _PUBLIC.netloc: + proto = _PUBLIC.scheme + else: + proto = request.headers.get("x-forwarded-proto") or request.url.scheme return f"{proto}://{host}" diff --git a/frontend/tests/unit/test_rendering.py b/frontend/tests/unit/test_rendering.py index 496580e..a10f9f8 100644 --- a/frontend/tests/unit/test_rendering.py +++ b/frontend/tests/unit/test_rendering.py @@ -29,6 +29,51 @@ def test_sitemap_lists_city_urls(client): assert f"/climate/{SLUG}/records" in r.text +# The public site is HTTPS-only behind Caddy, which terminates TLS and proxies +# plain HTTP -- so x-forwarded-proto / request.url.scheme read "http". A request +# arriving on the configured public host (THERMOGRAPH_BASE_URL defaults to +# https://thermograph.org in the test env) must still emit https:// in every +# frontend-built absolute URL, or canonicals self-conflict and the sitemap lists +# redirecting URLs. +_PUBLIC = {"host": "thermograph.org", "x-forwarded-proto": "http"} + + +def test_public_host_canonical_and_og_are_https(client): + b = client.get(f"{B}/climate/{SLUG}", headers=_PUBLIC).text + assert f'https://thermograph.org{B}/climate/{SLUG}" in body + assert "http://thermograph.org" not in body + + +def test_public_host_robots_sitemap_is_https(client): + body = client.get(f"{B}/robots.txt", headers=_PUBLIC).text + assert f"Sitemap: https://thermograph.org{B}/sitemap.xml" in body + + +def test_non_public_host_keeps_forwarded_scheme(client): + # A LAN/dev host never matches the configured public host, so the observed + # (plain-http) scheme is preserved -- http development is unaffected. + b = client.get(f"{B}/climate/{SLUG}", + headers={"host": "192.168.1.10:8000", "x-forwarded-proto": "http"}).text + assert f'