Emit https origin for the public host in canonical/og/sitemap URLs
The SSR frontend's _origin() (canonical, og:url, og:image, robots, sitemap <loc>) and the backend content API's _origin() (the jsonld url folded into each payload) both trusted x-forwarded-proto / request.url.scheme. Behind Caddy — which terminates TLS and reverse-proxies plain HTTP — those read "http", so on the HTTPS-only public site every absolute URL emitted http://, which 308-redirects to https://: canonicals self-conflict and the sitemap lists redirecting URLs. Take the scheme from the configured public origin (THERMOGRAPH_BASE_URL, set per-host in the deploy env) when the request arrives on that host, so prod and beta emit https. localhost and LAN dev never match the public host and keep the observed scheme, so plain-HTTP development is unchanged; the frontend keeps its X-Forwarded-Host precedence for the proxy-fallback path. Tests assert canonical/og:url/og:image, robots Sitemap and every sitemap <loc> are https for the public host (and via X-Forwarded-Host), that a LAN host stays http, and that the backend payload's jsonld url is likewise https.
This commit is contained in:
parent
248eeee110
commit
72e141afd3
4 changed files with 101 additions and 2 deletions
|
|
@ -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}"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 <loc>
|
||||
# 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}"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,51 @@ def test_sitemap_lists_city_urls(client):
|
|||
assert f"/climate/{SLUG}/records</loc>" 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'<link rel="canonical" href="https://thermograph.org{B}/climate/{SLUG}"' in b
|
||||
assert f'<meta property="og:url" content="https://thermograph.org{B}/climate/{SLUG}"' in b
|
||||
assert 'property="og:image" content="https://thermograph.org' in b
|
||||
|
||||
|
||||
def test_public_host_sitemap_locs_are_https(client):
|
||||
body = client.get(f"{B}/sitemap.xml", headers=_PUBLIC).text
|
||||
assert f"<loc>https://thermograph.org{B}/climate/{SLUG}</loc>" in body
|
||||
assert "<loc>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'<link rel="canonical" href="http://192.168.1.10:8000{B}/climate/{SLUG}"' in b
|
||||
|
||||
|
||||
def test_proxied_forwarded_host_still_https(client):
|
||||
# Reached via backend's proxy fallback: Host is the internal hop, the real
|
||||
# browser host arrives in X-Forwarded-Host -- which still resolves to the
|
||||
# public host and must go https.
|
||||
b = client.get(f"{B}/climate/{SLUG}",
|
||||
headers={"host": "frontend:8080", "x-forwarded-host": "thermograph.org",
|
||||
"x-forwarded-proto": "http"}).text
|
||||
assert f'<link rel="canonical" href="https://thermograph.org{B}/climate/{SLUG}"' in b
|
||||
|
||||
|
||||
def test_city_page_renders_structure(client):
|
||||
r = client.get(f"{B}/climate/{SLUG}")
|
||||
assert r.status_code == 200
|
||||
|
|
|
|||
Loading…
Reference in a new issue