From 52310dbf658346ab4c22c9f7c32fd578e734402b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 22 Jul 2026 12:19:25 +0200 Subject: [PATCH] Read portal SEO origin from request context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SNI middleware already stores scheme://host; SEOFromRequest no longer takes a base URL or re-strips the path. Expose context helpers and drop the obsolete strip tests. Signed-off-by: Émile Ré --- pkg/server/api/complianceportal/context.go | 10 ++ .../api/complianceportal/sni_middleware.go | 15 +-- pkg/server/api/complianceportal/v1/mux.go | 21 ++-- pkg/server/api/complianceportal/v1/seo.go | 28 ++---- .../api/complianceportal/v1/seo_test.go | 98 +++++-------------- 5 files changed, 57 insertions(+), 115 deletions(-) diff --git a/pkg/server/api/complianceportal/context.go b/pkg/server/api/complianceportal/context.go index b3f9b6a51..a880215d2 100644 --- a/pkg/server/api/complianceportal/context.go +++ b/pkg/server/api/complianceportal/context.go @@ -38,7 +38,17 @@ func CompliancePortalFromContext(ctx context.Context) *coredata.CompliancePortal return page } +func ContextWithCompliancePortal(ctx context.Context, page *coredata.CompliancePortal) context.Context { + return context.WithValue(ctx, compliancePortalKey, page) +} + func CompliancePortalBaseURLFromContext(ctx context.Context) *string { page, _ := ctx.Value(compliancePortalBaseURLKey).(*string) return page } + +// ContextWithCompliancePortalBaseURL stores the portal origin (scheme://host). +// Callers must not include a path — consumers append their own routes. +func ContextWithCompliancePortalBaseURL(ctx context.Context, baseURL string) context.Context { + return context.WithValue(ctx, compliancePortalBaseURLKey, &baseURL) +} diff --git a/pkg/server/api/complianceportal/sni_middleware.go b/pkg/server/api/complianceportal/sni_middleware.go index a37241920..63990d7b0 100644 --- a/pkg/server/api/complianceportal/sni_middleware.go +++ b/pkg/server/api/complianceportal/sni_middleware.go @@ -21,7 +21,6 @@ package complianceportal import ( - "context" "errors" "net/http" "net/url" @@ -115,21 +114,15 @@ func NewSNIMiddleware(visitorSvc *visitor.Service) func(next http.Handler) http. // Origin only — consumers append their own paths (SEO, sitemap, // robots, brand assets, OAuth). Including r.URL.Path here would // duplicate the route (e.g. /fr/documents/fr/documents). - baseURL := &url.URL{ + baseURL := (&url.URL{ Host: r.Host, Scheme: "https", - } - baseURLString := baseURL.String() - - ctx = context.WithValue( - ctx, - compliancePortalBaseURLKey, - &baseURLString, - ) + }).String() + ctx = ContextWithCompliancePortalBaseURL(ctx, baseURL) r = r.WithContext(ctx) if compliancePage.Active { - ctx = context.WithValue(ctx, compliancePortalKey, compliancePage) + ctx = ContextWithCompliancePortal(ctx, compliancePage) next.ServeHTTP(w, r.WithContext(ctx)) return diff --git a/pkg/server/api/complianceportal/v1/mux.go b/pkg/server/api/complianceportal/v1/mux.go index 21592c19f..fd0e92873 100644 --- a/pkg/server/api/complianceportal/v1/mux.go +++ b/pkg/server/api/complianceportal/v1/mux.go @@ -20,7 +20,6 @@ import ( "github.com/go-chi/chi/v5" "go.gearno.de/kit/log" - "go.gearno.de/x/ref" "go.probo.inc/probo/pkg/baseurl" visitor "go.probo.inc/probo/pkg/complianceportal/visitor" "go.probo.inc/probo/pkg/esign" @@ -140,19 +139,15 @@ func compliancePageHeadData() HeadDataFunc { description = *tc.Description } - headData := HeadData{ - Title: tc.Title, - Description: description, - } + htmlLang, canonical, hreflang := SEOFromRequest(r) - // Canonical / hreflang / og:url must be absolute. Without a portal - // origin in context, skip them rather than emit relative paths. - if pageBase := ref.UnrefOrZero(compliancePageBaseURL); pageBase != "" { - htmlLang, canonical, hreflang := SEOFromRequest(r, pageBase) - headData.HTMLLang = htmlLang - headData.OGURL = canonical - headData.CanonicalURL = canonical - headData.Hreflang = hreflang + headData := HeadData{ + Title: tc.Title, + Description: description, + HTMLLang: htmlLang, + OGURL: canonical, + CanonicalURL: canonical, + Hreflang: hreflang, } if tc.LogoFileID != nil && compliancePageBaseURL != nil { diff --git a/pkg/server/api/complianceportal/v1/seo.go b/pkg/server/api/complianceportal/v1/seo.go index cd709b2c5..4e19b0a28 100644 --- a/pkg/server/api/complianceportal/v1/seo.go +++ b/pkg/server/api/complianceportal/v1/seo.go @@ -26,7 +26,9 @@ import ( "slices" "strings" + "go.gearno.de/x/ref" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/server/api/complianceportal" ) const defaultCompliancePortalLocale = "en" @@ -34,11 +36,11 @@ const defaultCompliancePortalLocale = "en" // SEOFromRequest derives html lang, a self-referencing canonical URL, and // hreflang alternates (including x-default → English) for the SPA shell. // Portals are host-routed (slug subdomain / custom domain); the request path -// is already relative to the portal root. pageBaseURL is normalized to its -// origin so a stale path in the base cannot double the route. When pageBaseURL -// has no usable origin, canonical and hreflang are left empty so callers do -// not emit relative SEO links. -func SEOFromRequest(r *http.Request, pageBaseURL string) (htmlLang, canonical string, hreflang []HreflangLink) { +// is already relative to the portal root. The portal origin comes from +// request context (set by SNI middleware as scheme://host). When missing, +// canonical and hreflang are left empty so callers do not emit relative SEO +// links. +func SEOFromRequest(r *http.Request) (htmlLang, canonical string, hreflang []HreflangLink) { pathname := r.URL.Path if pathname == "" { pathname = "/" @@ -47,10 +49,8 @@ func SEOFromRequest(r *http.Request, pageBaseURL string) (htmlLang, canonical st locale, rest := splitLocaleFromAppPath(pathname) htmlLang = locale - origin := portalOrigin(pageBaseURL) + origin := ref.UnrefOrZero(complianceportal.CompliancePortalBaseURLFromContext(r.Context())) if origin == "" { - // No absolute origin — return lang only; callers must not emit - // relative canonical/hreflang URLs. return htmlLang, "", nil } @@ -74,18 +74,6 @@ func SEOFromRequest(r *http.Request, pageBaseURL string) (htmlLang, canonical st return htmlLang, canonical, hreflang } -// portalOrigin returns scheme://host from pageBaseURL, dropping any path, -// query, or fragment. Callers may pass a full request URL by mistake. -// Relative or malformed values yield "" so SEO links are omitted. -func portalOrigin(pageBaseURL string) string { - parsed, err := url.Parse(pageBaseURL) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return "" - } - - return (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host}).String() -} - func splitLocaleFromAppPath(appPath string) (locale, rest string) { segments := strings.Split(strings.Trim(appPath, "/"), "/") if len(segments) == 0 || segments[0] == "" { diff --git a/pkg/server/api/complianceportal/v1/seo_test.go b/pkg/server/api/complianceportal/v1/seo_test.go index d1af5400e..e713067e5 100644 --- a/pkg/server/api/complianceportal/v1/seo_test.go +++ b/pkg/server/api/complianceportal/v1/seo_test.go @@ -26,23 +26,35 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/server/api/complianceportal" complianceportal_v1 "go.probo.inc/probo/pkg/server/api/complianceportal/v1" ) +func requestWithPortalOrigin(t *testing.T, rawURL, origin string) *http.Request { + t.Helper() + + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + require.NoError(t, err) + + if origin != "" { + req = req.WithContext( + complianceportal.ContextWithCompliancePortalBaseURL(req.Context(), origin), + ) + } + + return req +} + func TestSEOFromRequest(t *testing.T) { t.Parallel() - req, err := http.NewRequest( - http.MethodGet, + req := requestWithPortalOrigin( + t, "https://acme.probopage.localhost/fr/documents", - nil, - ) - require.NoError(t, err) - - lang, canonical, hreflang := complianceportal_v1.SEOFromRequest( - req, "https://acme.probopage.localhost", ) + + lang, canonical, hreflang := complianceportal_v1.SEOFromRequest(req) assert.Equal(t, "fr", lang) assert.Equal(t, "https://acme.probopage.localhost/fr/documents", canonical) require.NotEmpty(t, hreflang) @@ -69,78 +81,22 @@ func TestSEOFromRequest(t *testing.T) { func TestSEOFromRequest_EscapesPathSegments(t *testing.T) { t.Parallel() - req, err := http.NewRequest( - http.MethodGet, + req := requestWithPortalOrigin( + t, "https://acme.probopage.localhost/en/docs/foo%20bar", - nil, - ) - require.NoError(t, err) - - _, canonical, _ := complianceportal_v1.SEOFromRequest( - req, "https://acme.probopage.localhost", ) + + _, canonical, _ := complianceportal_v1.SEOFromRequest(req) assert.Equal(t, "https://acme.probopage.localhost/en/docs/foo%20bar", canonical) } -func TestSEOFromRequest_StripsPathFromBaseURL(t *testing.T) { +func TestSEOFromRequest_MissingOriginOmitsLinks(t *testing.T) { t.Parallel() - req, err := http.NewRequest( - http.MethodGet, - "https://trust.acme.com/fr/documents", - nil, - ) - require.NoError(t, err) + req := requestWithPortalOrigin(t, "https://trust.acme.com/fr/documents", "") - // SNI middleware once stored scheme+host+path; SEO must not double it. - _, canonical, hreflang := complianceportal_v1.SEOFromRequest( - req, - "https://trust.acme.com/fr/documents", - ) - assert.Equal(t, "https://trust.acme.com/fr/documents", canonical) - - var enHref string - - for _, link := range hreflang { - if link.Lang == "en" { - enHref = link.Href - } - } - - assert.Equal(t, "https://trust.acme.com/en/documents", enHref) -} - -func TestSEOFromRequest_EmptyBaseURLOmitsLinks(t *testing.T) { - t.Parallel() - - req, err := http.NewRequest( - http.MethodGet, - "https://trust.acme.com/fr/documents", - nil, - ) - require.NoError(t, err) - - lang, canonical, hreflang := complianceportal_v1.SEOFromRequest(req, "") - assert.Equal(t, "fr", lang) - assert.Empty(t, canonical) - assert.Nil(t, hreflang) -} - -func TestSEOFromRequest_RelativeBaseURLOmitsLinks(t *testing.T) { - t.Parallel() - - req, err := http.NewRequest( - http.MethodGet, - "https://trust.acme.com/fr/documents", - nil, - ) - require.NoError(t, err) - - lang, canonical, hreflang := complianceportal_v1.SEOFromRequest( - req, - "/fr/documents", - ) + lang, canonical, hreflang := complianceportal_v1.SEOFromRequest(req) assert.Equal(t, "fr", lang) assert.Empty(t, canonical) assert.Nil(t, hreflang)