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'