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
This commit is contained in:
203
addresses_test.go
Normal file
203
addresses_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
155
classes/normalize.go
Normal file
155
classes/normalize.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
68
classes/strict.go
Normal file
68
classes/strict.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -13,11 +13,14 @@ import (
|
||||
var log = diary.For("seo")
|
||||
|
||||
// Resolve serves GET /_seo/api/v1/resolve?url=<path or absolute URL>: the
|
||||
// effective SEO of a public URL for the frontend's <head>. 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()
|
||||
|
||||
Reference in New Issue
Block a user