From 4cb0fbfebb7fa98be8f25b9ae2636202b5975026 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 27 Oct 2026 11:41:45 +0100 Subject: [PATCH] feat: URL normalisation, strict URLs and the one-hop redirect decision - Normalize: https, www or bare host, double slashes and index files, trailing slash add/remove, optional ASCII lowercasing (defaults change nothing but scheme, slashes and index files) - strict URLs through signpost MatchPath: a path that names a record without being its address 301s to the record's address - resolve runs normalisation, redirects, strict URLs, then SEO; the location is the final address, so a visitor makes one hop - P1 warns when a record's canonical URL redirects --- addresses_test.go | 203 +++++++++++++++++++++++++++++++++++++ classes/normalize.go | 155 ++++++++++++++++++++++++++++ classes/partials.go | 23 +++-- classes/resolver.go | 132 ++++++++++++++++-------- classes/strict.go | 68 +++++++++++++ controllers/api/resolve.go | 13 ++- 6 files changed, 539 insertions(+), 55 deletions(-) create mode 100644 addresses_test.go create mode 100644 classes/normalize.go create mode 100644 classes/strict.go diff --git a/addresses_test.go b/addresses_test.go new file mode 100644 index 0000000..1c0ae08 --- /dev/null +++ b/addresses_test.go @@ -0,0 +1,203 @@ +package seo_test + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" + + "git.golem15.com/golem15/sm-seo-plugin/classes" + "git.golem15.com/golem15/sm-seo-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/signpost" +) + +// blogPlugin is acme.blog: posts under /blog/:category/:slug with a +// translated slug in pl (/pl/blog/:category/:slug), kept in memory. It +// stands in for a content type whose URL has more segments than the +// record's own slug (D-20c). +type blogPlugin struct{} + +type blogPost struct { + id, category, slug, plSlug string +} + +var blogPosts = []blogPost{{id: "1", category: "news", slug: "hello", plSlug: "witaj"}} + +func (blogPlugin) ID() string { return "acme.blog" } +func (blogPlugin) Requires() []string { return nil } +func (blogPlugin) Register(*backpack.App) error { return nil } +func (blogPlugin) Boot(*backpack.App) error { return nil } + +func (blogPlugin) MenuItemTypes() []pact.MenuItemType { + return []pact.MenuItemType{{ + Key: "acme-post", + Label: "Posts", + DefaultPattern: "/blog/:category/:slug", + Placeholders: []string{"category", "slug"}, + Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) { + return nil, nil + }, + Content: &pact.ContentDescriptor{ + Model: "acme_blog_posts", + Describe: func(_ context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) { + for _, p := range blogPosts { + if p.id == in.ID { + return &pact.ContentRecord{ID: p.id, Published: true, Locales: []pact.ContentLocale{ + {Locale: "en", URL: "/blog/" + p.category + "/" + p.slug, Title: "Hello"}, + {Locale: "pl", URL: "/pl/blog/" + p.category + "/" + p.plSlug, Title: "Witaj"}, + }}, nil + } + } + return nil, nil + }, + Match: func(_ context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) { + path, locale := in.Path, "en" + if rest, ok := strings.CutPrefix(path, "/pl/"); ok { + path, locale = "/"+rest, "pl" + } + values, ok := signpost.MatchPattern(in.Pattern, path) + if !ok { + return nil, nil + } + for _, p := range blogPosts { + own := p.slug + if locale == "pl" { + own = p.plSlug + } + if values["slug"] == p.slug || values["slug"] == p.plSlug { + return &pact.ContentMatchResult{ID: p.id, Locale: locale, Exact: values["category"] == p.category && values["slug"] == own}, nil + } + } + return nil, nil + }, + }, + }} +} + +var _ pact.HasMenuItemTypes = blogPlugin{} + +func init() { + party.Register(blogPlugin{}) +} + +// TestNormalize checks the address rules (D-20b, RESEARCH Pitfall 13): +// pure, no database. +func TestNormalize(t *testing.T) { + keep := classes.NormalizeOptions{Host: models.HostKeep, Slash: models.SlashKeep} + cases := []struct { + name string + in string + opts classes.NormalizeOptions + want string + changed bool + }{ + {"http to https", "http://www.example.test/a", keep, "https://www.example.test/a", true}, + {"clean", "https://www.example.test/a", keep, "https://www.example.test/a", false}, + {"clean path", "/pages/a?page=2", keep, "/pages/a?page=2", false}, + {"double slashes", "/a//b///c", keep, "/a/b/c", true}, + {"index file", "/blog/index.html", keep, "/blog", true}, + {"root index", "/index.php", keep, "/", true}, + {"fragment", "/a#top", keep, "/a", false}, + {"www added", "https://example.test/a", classes.NormalizeOptions{Host: models.HostWWW}, "https://www.example.test/a", true}, + {"www kept", "https://www.example.test/a", classes.NormalizeOptions{Host: models.HostWWW}, "https://www.example.test/a", false}, + {"www dropped", "https://www.example.test/a", classes.NormalizeOptions{Host: models.HostBare}, "https://example.test/a", true}, + {"slash added", "/a", classes.NormalizeOptions{Slash: models.SlashAdd}, "/a/", true}, + {"slash not added to a file", "/sitemap.xml", classes.NormalizeOptions{Slash: models.SlashAdd}, "/sitemap.xml", false}, + {"slash removed", "/a/", classes.NormalizeOptions{Slash: models.SlashDrop}, "/a", true}, + {"root kept (add)", "/", classes.NormalizeOptions{Slash: models.SlashAdd}, "/", false}, + {"root kept (remove)", "/", classes.NormalizeOptions{Slash: models.SlashDrop}, "/", false}, + {"lowercase", "/Blog/Post", classes.NormalizeOptions{Lowercase: true}, "/blog/post", true}, + {"lowercase off", "/Blog/Post", keep, "/Blog/Post", false}, + {"polish letters kept", "/%C5%81%C3%B3d%C5%BA/Abc", classes.NormalizeOptions{Lowercase: true}, "/%C5%81%C3%B3d%C5%BA/abc", true}, + {"percent case is the same address", "/%c5%81odz", classes.NormalizeOptions{Lowercase: true}, "/%C5%81odz", false}, + {"all at once", "http://example.test//Blog//index.html", classes.NormalizeOptions{Host: models.HostWWW, Slash: models.SlashAdd, Lowercase: true}, "https://www.example.test/blog/", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + u, err := url.Parse(c.in) + if err != nil { + t.Fatal(err) + } + got, changed := classes.Normalize(u, c.opts) + if got.String() != c.want || changed != c.changed { + t.Fatalf("Normalize(%s) = %s, %v; want %s, %v", c.in, got, changed, c.want, c.changed) + } + if u.String() != c.in && !strings.Contains(c.in, "#") { + t.Fatalf("Normalize modified its input: %s", u) + } + }) + } + // The Polish case decodes to the same letters it started with. + u, _ := url.Parse("/%C5%81%C3%B3d%C5%BA") + if got, _ := classes.Normalize(u, classes.NormalizeOptions{Lowercase: true}); got.Path != "/Łódź" { + t.Fatalf("decoded Polish path = %q, want /Łódź", got.Path) + } +} + +// TestStrictURL checks strict URLs (D-20c): a path that names a record +// with a wrong segment or another locale's slug 301s to the record's own +// address, and only its own address answers ok. +func TestStrictURL(t *testing.T) { + env := bootPlugins(t, []string{"acme.pages", "acme.blog", "golem15.seo"}) + env.expectAction(t, "/blog/news/hello", "ok") + env.expectRedirect(t, "/blog/wrong-category/hello", 301, siteURL+"/blog/news/hello") + env.expectRedirect(t, "/blog/wrong-category/hello?page=2", 301, siteURL+"/blog/news/hello?page=2") + env.expectRedirect(t, "/pl/blog/news/hello", 301, siteURL+"/pl/blog/news/witaj") + env.expectRedirect(t, "/blog/news/witaj", 301, siteURL+"/blog/news/hello") + got := env.expectAction(t, "/pl/blog/news/witaj", "ok") + if got["locale"] != "pl" { + t.Fatalf("pl address = %v, want locale pl", got) + } + env.expectAction(t, "/blog/news/missing", "not_found") + + token := env.admin(t, true) + env.createPage(t, token, map[string]any{"title": "Alpha", "slug": "a", "published": true}) + env.expectAction(t, "/pages/a", "ok") + env.expectRedirect(t, "/pages/a/", 301, siteURL+"/pages/a") +} + +// TestOneHop checks that the resolve answer is the redirect decision +// (D-20a, D-20b, P-21, D-06): normalisation, redirects and strict URLs +// reach the final address in one hop, a runtime cycle stops with +// not_found, and P1 warns about a canonical that redirects. +func TestOneHop(t *testing.T) { + env := bootEnv(t) + token := env.admin(t, true) + id := env.createPage(t, token, map[string]any{"title": "Alpha", "slug": "a", "published": true}) + if err := env.db.Exec(`UPDATE golem15_seo_settings SET url_slash = 'add', url_lowercase = TRUE WHERE id = 1`).Error; err != nil { + t.Fatal(err) + } + + env.mustRedirect(t, "/old-page", "exact", "/pages/a", 301) + env.expectRedirect(t, "http://www.example.test//OLD-PAGE", 301, siteURL+"/pages/a/") + env.expectRedirect(t, "/Old-Page/index.html", 301, siteURL+"/pages/a/") + env.mustRedirect(t, "/x2", "exact", "/old-page", 301) + env.expectRedirect(t, "/X2", 301, siteURL+"/pages/a/") + env.expectAction(t, "/pages/a/", "ok") + env.expectRedirect(t, "/pages/a", 301, siteURL+"/pages/a/") + env.expectRedirect(t, "/Pages/A", 301, siteURL+"/pages/a/") + env.expectAction(t, "/", "ok") + got := env.resolve(t, "https://attacker.example/pages/a/") + if got["action"] != "ok" { + t.Fatalf("a foreign host = %v, want its path resolved on the site", got) + } + + // A cycle seeded past the save-time check stops after the hop cap. + env.seedRedirect(t, "/c1", "exact", "/c2", 301) + env.seedRedirect(t, "/c2", "exact", "/c1", 301) + if got := env.resolve(t, "/c1/"); got["action"] != "not_found" || got["status"] != float64(404) { + t.Fatalf("runtime cycle = %v, want not_found", got) + } + + env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), token, map[string]any{ + "title": "Alpha", "slug": "a", "published": true, "seo_canonical": map[string]any{"en": "/old-page"}, + }) + status := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/acme/pages/pages/partials/seo_status?id="+id), token, nil).Body.String() + if !strings.Contains(status, "The canonical URL “/old-page” redirects to “"+siteURL+"/pages/a/”") { + t.Fatalf("P1 = %s, want status.canonical_redirected", status) + } +} diff --git a/classes/normalize.go b/classes/normalize.go new file mode 100644 index 0000000..bdf2684 --- /dev/null +++ b/classes/normalize.go @@ -0,0 +1,155 @@ +package classes + +import ( + "net/url" + "strings" + + "git.golem15.com/golem15/sm-seo-plugin/models" +) + +// NormalizeOptions are a site's address rules (D-20b, settings tab +// Addresses): Host is keep, www or bare; Slash is keep, add or remove; +// Lowercase lowercases the ASCII letters of the path. The defaults (keep, +// keep, off) change nothing but scheme, double slashes and index files +// (UI-SPEC Q-7). +type NormalizeOptions struct { + Host string + Slash string + Lowercase bool +} + +// NormalizeOptionsOf reads the address rules of the settings. +func NormalizeOptionsOf(s models.Settings) NormalizeOptions { + return NormalizeOptions{Host: s.URLHost, Slash: s.URLSlash, Lowercase: s.URLLowercase} +} + +// indexFiles are the directory index names collapsed into their directory. +var indexFiles = []string{"/index.html", "/index.htm", "/index.php"} + +// Normalize returns u in the site's address form and whether anything +// changed (D-20b). It applies every rule at once, so any other form reaches +// the final one in one hop: +// - an absolute URL gets https, and its host gains or loses "www." per +// opts.Host (keep leaves it); +// - repeated slashes collapse and a trailing /index.html (.htm, .php) +// collapses into its directory; +// - with opts.Lowercase the ASCII letters of the decoded path are +// lowercased; other letters (Ł) and the percent-encoding are kept, so +// %C5%81 and %c5%81 stay the same address (RESEARCH Pitfall 13); +// - opts.Slash add appends a trailing slash to a path whose last segment +// has no file extension, remove drops it; the root / never changes. +// +// The query is kept as it is and a fragment is dropped. u is not modified. +func Normalize(u *url.URL, opts NormalizeOptions) (*url.URL, bool) { + if u == nil { + return nil, false + } + out := *u + out.Fragment, out.RawFragment = "", "" + changed := false + + if out.Host != "" { + if out.Scheme != "https" { + out.Scheme, changed = "https", true + } + host := strings.ToLower(out.Host) + switch opts.Host { + case models.HostWWW: + if !strings.HasPrefix(host, "www.") { + host = "www." + host + } + case models.HostBare: + host = strings.TrimPrefix(host, "www.") + } + if host != strings.ToLower(out.Host) { + changed = true + } + out.Host = host + } + + p := out.Path + if p == "" { + p = "/" + } + clean := collapseSlashes(p) + for _, index := range indexFiles { + if strings.HasSuffix(strings.ToLower(clean), index) { + clean = clean[:len(clean)-len(index)] + if clean == "" { + clean = "/" + } + break + } + } + if opts.Lowercase { + clean = lowerASCII(clean) + } + if clean != "/" { + switch opts.Slash { + case models.SlashAdd: + if !strings.HasSuffix(clean, "/") && !strings.Contains(lastSegment(clean), ".") { + clean += "/" + } + case models.SlashDrop: + clean = strings.TrimRight(clean, "/") + if clean == "" { + clean = "/" + } + } + } + if clean != p { + changed = true + } + out.Path, out.RawPath = clean, "" + return &out, changed +} + +// collapseSlashes replaces every run of slashes with one. +func collapseSlashes(p string) string { + if !strings.Contains(p, "//") { + return p + } + var b strings.Builder + prev := false + for i := 0; i < len(p); i++ { + if p[i] == '/' { + if prev { + continue + } + prev = true + } else { + prev = false + } + b.WriteByte(p[i]) + } + return b.String() +} + +// lowerASCII lowercases the ASCII letters of s only. +func lowerASCII(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'A' && c <= 'Z' { + b[i] = c + ('a' - 'A') + } + } + return string(b) +} + +// lastSegment is the part of p after its last slash. +func lastSegment(p string) string { + if i := strings.LastIndex(p, "/"); i >= 0 { + return p[i+1:] + } + return p +} + +// sameSite reports whether host is the site URL's host or its www twin. +func sameSite(site, host string) bool { + su, err := url.Parse(site) + if err != nil || su.Host == "" || host == "" { + return false + } + bare := func(h string) string { return strings.TrimPrefix(strings.ToLower(h), "www.") } + return bare(su.Host) == bare(host) +} diff --git a/classes/partials.go b/classes/partials.go index 81c87a6..a7d53a0 100644 --- a/classes/partials.go +++ b/classes/partials.go @@ -220,24 +220,31 @@ func (s *Service) ruleCallout(ctx context.Context, rules []models.Rule) (Callout return Callout{}, false } -// canonicalCallout warns when a canonical on this site resolves to a page -// served noindex. Outside production every page is noindex, so it says -// nothing there. +// canonicalCallout warns about a canonical on this site (D-06): one that +// redirects (status.canonical_redirected, naming the final address), or +// one that resolves to a page served noindex (status.canonical_noindex). +// Outside production every page is noindex, so only the redirect warning +// applies there. func (s *Service) canonicalCallout(ctx context.Context, canonical string) (Callout, bool) { canonical = strings.TrimSpace(canonical) - if canonical == "" || s.Staging() { + if canonical == "" { return Callout{}, false } target := canonical if u, err := url.Parse(canonical); err == nil && u.IsAbs() { - site, err := url.Parse(s.SiteURL()) - if err != nil || site.Host == "" || !strings.EqualFold(site.Host, u.Host) { + if !sameSite(s.SiteURL(), u.Host) { return Callout{}, false } - target = u.RequestURI() + target = u.String() } res, err := s.Resolver().Resolve(ctx, target) - if err != nil || res.SEO == nil || !strings.Contains(res.SEO.Robots, "noindex") { + if err != nil { + return Callout{}, false + } + if res.Action == ActionRedirect { + return Callout{Title: s.PhraseCtx(ctx, "golem15.seo::lang.status.canonical_redirected", map[string]string{"url": canonical, "target": res.Location})}, true + } + if s.Staging() || res.SEO == nil || !strings.Contains(res.SEO.Robots, "noindex") { return Callout{}, false } return Callout{Title: s.PhraseCtx(ctx, "golem15.seo::lang.status.canonical_noindex", map[string]string{"url": canonical})}, true diff --git a/classes/resolver.go b/classes/resolver.go index 5f3150c..8f1dc7f 100644 --- a/classes/resolver.go +++ b/classes/resolver.go @@ -218,26 +218,6 @@ func parseTarget(raw string) (string, url.Values, error) { return path, u.Query(), nil } -// lookup finds the published record behind path; key is "" when none. -func (r *Resolver) lookup(ctx context.Context, path string) (key string, match *pact.ContentMatchResult, rec *pact.ContentRecord, err error) { - reg := r.Service.Registry() - if reg == nil { - return "", nil, nil, nil - } - key, match, err = reg.MatchPath(ctx, path, r.Service.UnprefixedLocale()) - if err != nil || match == nil { - return "", nil, nil, err - } - rec, err = reg.Describe(ctx, key, match.ID, r.Service.UnprefixedLocale()) - if err != nil { - return "", nil, nil, err - } - if rec == nil || !rec.Published { - return "", nil, nil, nil - } - return key, match, rec, nil -} - // contentLocale returns the record's description in locale, else its first // locale. func contentLocale(rec *pact.ContentRecord, locale string) pact.ContentLocale { @@ -339,19 +319,33 @@ func resolveRobots(layers []robotsLayer) (Robots, string) { return out.Inherit(Robots{Index: "index", Follow: "follow"}), source } -// Resolve answers the effective SEO of rawURL, a path or an absolute URL of -// the site, with the D-02 precedence per value (P-14): URL rule > record > -// per-type template or global settings > content-derived values. A path -// with neither a published record nor a URL rule answers not_found, except -// the home page. Outside production every answer is noindex,nofollow with -// staging true (D-22). Absolute URLs come from SiteURL, never from a -// request. +// Resolve answers rawURL, a path or an absolute URL of the site: the +// redirect decision first, then the effective SEO (D-20a, D-103, P-21). +// The pipeline is: +// 1. normalisation (D-20b): every address rule at once; +// 2. redirects on the normalised path, followed to the final target +// (a 410 answers gone, a cycle not_found); +// 3. strict URLs (D-20c): a path that names a record without being its +// address 301s to the record's address; +// 4. the effective SEO with the D-02 precedence per value (P-14): URL rule +// > record > per-type template or global settings > content-derived +// values. +// +// When normalisation changed the URL or a redirect matched, the answer's +// location is the final address, so a visitor makes one hop. A path with +// neither a published record nor a URL rule answers not_found, except the +// home page. Outside production every answer is noindex,nofollow with +// staging true (D-22). Absolute URLs come from SiteURL, or from the +// request's own URL when that is the site's host or its www twin; never +// from a request Host header. func (r *Resolver) Resolve(ctx context.Context, rawURL string) (Result, error) { path, query, err := parseTarget(rawURL) if err != nil { return Result{}, err } + rawQuery := rawQueryOf(rawURL) svc := r.Service + site := svc.SiteURL() db, err := svc.DB(ctx) if err != nil { return Result{}, err @@ -360,22 +354,80 @@ func (r *Resolver) Resolve(ctx context.Context, rawURL string) (Result, error) { if err != nil { return Result{}, err } + opts := NormalizeOptionsOf(settings) + self := site + joinQuery(path, rawQuery) + + // 1. Normalisation. An absolute URL of the site (or its www twin) + // keeps its scheme and host for the scheme and host rules; any other + // host is ignored. + in := &url.URL{Path: path, RawQuery: rawQuery} + if u, err := url.Parse(strings.TrimSpace(rawURL)); err == nil && u.IsAbs() && sameSite(site, u.Host) { + in.Scheme, in.Host = u.Scheme, u.Host + } + norm, changed := Normalize(in, opts) + origin := site + if norm.Host != "" { + origin = norm.Scheme + "://" + norm.Host + } + npath, nquery := norm.Path, norm.RawQuery + + // 2. Redirects on the normalised path. set, err := LoadRedirectSet(db) if err != nil { return Result{}, err } - chain, err := set.Follow(svc.SiteURL(), path, rawQueryOf(rawURL), MaxRedirectHops) + chain, err := set.Follow(site, npath, nquery, MaxRedirectHops) if err != nil { return Result{}, err } if len(chain.Hops) > 0 { - return r.redirectAnswer(svc.SiteURL()+joinQuery(path, rawQueryOf(rawURL)), chain), nil + if chain.Loop || chain.Gone { + return r.redirectAnswer(self, chain), nil + } + location := chain.Final + if fpath, fquery, internal := internalTarget(site, chain.Final); internal { + final, _ := Normalize(&url.URL{Path: fpath, RawQuery: fquery}, opts) + fpath = final.Path + st, err := r.Strict(ctx, fpath, opts) + if err != nil { + return Result{}, err + } + if st.Key != "" && st.Mismatch && st.Canonical != "" { + fpath = st.Canonical + } + location = origin + joinQuery(escapePath(fpath), final.RawQuery) + } + res := Result{Action: ActionRedirect, Status: chain.Status, Location: location, URL: self, Staging: svc.Staging(), Sources: map[string]string{}} + return res, nil } - key, match, rec, err := r.lookup(ctx, path) + + // 3. Strict URLs. + st, err := r.Strict(ctx, npath, opts) if err != nil { return Result{}, err } - return r.resolveAt(ctx, db, settings, path, query, rawQueryOf(rawURL), key, match, rec, false) + if st.Key != "" && st.Mismatch { + if st.Canonical == "" { + return Result{Action: ActionNotFound, Status: 404, URL: self, Staging: svc.Staging(), Sources: map[string]string{}}, nil + } + return r.moved(self, origin+joinQuery(escapePath(st.Canonical), nquery)), nil + } + if changed { + return r.moved(self, origin+joinQuery(escapePath(npath), nquery)), nil + } + + // 4. The effective SEO. + return r.resolveAt(ctx, db, settings, npath, query, nquery, st.Key, st.Match, st.Record, false) +} + +// moved is a 301 answer to location. +func (r *Resolver) moved(self, location string) Result { + return Result{Action: ActionRedirect, Status: int(models.Status301), Location: location, URL: self, Staging: r.Service.Staging(), Sources: map[string]string{}} +} + +// escapePath percent-encodes a decoded path for a Location. +func escapePath(p string) string { + return (&url.URL{Path: p}).EscapedPath() } // ResolveRecord answers the effective SEO of a record in one locale at its @@ -571,21 +623,17 @@ func (r *Resolver) resolveAt(ctx context.Context, db *gorm.DB, settings models.S return res, nil } -// redirectAnswer is the answer of a matched redirect chain (P-21): gone -// for a chain that ends on a 410, not_found (logged once per URL) for a -// cycle, else redirect with the chain's status and its final target -// absolute on the site URL, so the visitor makes one hop. +// redirectAnswer is the answer of a redirect chain that leads nowhere: +// gone for a chain that ends on a 410, not_found (logged once per URL) for +// a cycle (P-22). func (r *Resolver) redirectAnswer(self string, ch Chain) Result { res := Result{URL: self, Staging: r.Service.Staging(), Sources: map[string]string{}} - switch { - case ch.Loop: - logRedirectCycle(self, ch) - res.Action, res.Status = ActionNotFound, 404 - case ch.Gone: + if ch.Gone && !ch.Loop { res.Action, res.Status = ActionGone, int(models.Status410) - default: - res.Action, res.Status, res.Location = ActionRedirect, ch.Status, AbsoluteURL(r.Service.SiteURL(), ch.Final) + return res } + logRedirectCycle(self, ch) + res.Action, res.Status = ActionNotFound, 404 return res } diff --git a/classes/strict.go b/classes/strict.go new file mode 100644 index 0000000..bc60fff --- /dev/null +++ b/classes/strict.go @@ -0,0 +1,68 @@ +package classes + +import ( + "context" + "net/url" + "strings" + + "git.golem15.com/golem15/summercms/modules/pact" +) + +// StrictMatch is the record behind a path and whether the path is its +// address (D-20c). Key is "" when no published record answers the path. +type StrictMatch struct { + Key string + Match *pact.ContentMatchResult + Record *pact.ContentRecord + // Canonical is the record's own path in the matched locale, in the + // site's address form; "" when the descriptor answers none. + Canonical string + // Mismatch is true when the path names the record but is not its + // address: a wrong category or parent, a locale prefix with another + // locale's slug, a moved record. It then 301s to Canonical. + Mismatch bool +} + +// Strict finds the published record behind path through the signpost +// patterns (MatchPath) and checks every segment against the record's own +// address (D-20c): a path that names a record without being its address +// is a mismatch, so a wrong URL never answers ok. The comparison uses the +// site's address form (opts), so a strict 301 and normalisation never +// send a visitor back and forth. +func (r *Resolver) Strict(ctx context.Context, path string, opts NormalizeOptions) (StrictMatch, error) { + var out StrictMatch + reg := r.Service.Registry() + if reg == nil { + return out, nil + } + unprefixed := r.Service.UnprefixedLocale() + // Content patterns carry no trailing slash; the site's trailing-slash + // rule is applied to the record's address below. + key, match, err := reg.MatchPath(ctx, trimSlash(path), unprefixed) + if err != nil || match == nil { + return out, err + } + rec, err := reg.Describe(ctx, key, match.ID, unprefixed) + if err != nil || rec == nil || !rec.Published { + return out, err + } + out.Key, out.Match, out.Record = key, match, rec + described := "" + for _, l := range rec.Locales { + if l.Locale == match.Locale { + described = strings.TrimSpace(l.URL) + } + } + if described == "" { + out.Mismatch = !match.Exact + return out, nil + } + du, err := url.Parse(described) + if err != nil { + return out, nil + } + canonical, _ := Normalize(&url.URL{Path: du.Path}, opts) + out.Canonical = canonical.Path + out.Mismatch = !match.Exact || canonical.Path != path + return out, nil +} diff --git a/controllers/api/resolve.go b/controllers/api/resolve.go index 30e6f46..92a126d 100644 --- a/controllers/api/resolve.go +++ b/controllers/api/resolve.go @@ -13,11 +13,14 @@ import ( var log = diary.For("seo") // Resolve serves GET /_seo/api/v1/resolve?url=: the -// effective SEO of a public URL for the frontend's . It answers 200 -// with the resolve answer (action ok or not_found, the status the page -// should answer with) and 422 when url is missing or not a path or an -// http(s) URL. Absolute URLs come from the configured site URL, never from -// the request's Host. +// redirect decision and the effective SEO of a public URL for the +// frontend (D-20a, D-103). It answers 200 with the resolve answer: action +// redirect with status (301 or 302) and an absolute location, reached in +// one hop through normalisation, redirects and strict URLs; gone with 410; +// ok with the page's SEO; or not_found with 404, the status the page +// should answer with. It answers 422 when url is missing or not a path or +// an http(s) URL. Absolute URLs come from the configured site URL, never +// from the request's Host. The frontend enforces the decision (Phase 33). func Resolve(service func() *classes.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { svc := service()