From 34a65b846ed83ffdbf8289d75a67ed09736de889 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 27 Oct 2026 11:26:42 +0100 Subject: [PATCH] feat: redirect engine with loop refusal and chain notices - rule set: exact sources by key, patterns by literal prefix, regex by id (stdlib RE2, compiled once per source); ignore case, trailing slash, query ignore/pass, a source with ? matches its query exactly - targets: captures escaped, a relative target never leaves the site, an absolute one keeps its host - SaveRedirect validates, simulates the rule in place (exact, pattern and regex probes, 10 hops) and refuses a loop; chains answer the notice - resolve follows the chain to its final target, 410 answers gone, a runtime cycle answers not_found and is logged once --- classes/redirect_graph.go | 337 +++++++++++ classes/redirects.go | 524 +++++++++++++++++- classes/resolver.go | 29 +- lang/en/lang.yaml | 8 + lang/pl/lang.yaml | 8 + models/redirect.go | 124 ++++- redirects_test.go | 202 +++++++ ...2610270006_create_golem15_seo_redirects.go | 10 +- 8 files changed, 1221 insertions(+), 21 deletions(-) create mode 100644 classes/redirect_graph.go diff --git a/classes/redirect_graph.go b/classes/redirect_graph.go new file mode 100644 index 0000000..cd4b815 --- /dev/null +++ b/classes/redirect_graph.go @@ -0,0 +1,337 @@ +package classes + +import ( + "context" + "errors" + "regexp/syntax" + "strconv" + "strings" + "time" + "unicode/utf8" + + "git.golem15.com/golem15/sm-seo-plugin/models" + "git.golem15.com/golem15/summercms/modules/pact" + "gorm.io/gorm" +) + +// candidateID stands in for the id of a redirect that is not stored yet. +const candidateID = ^uint(0) + +// RedirectCheck is what the save-time simulation found for one redirect +// (D-19, P-19). +type RedirectCheck struct { + // Loop is true when following redirects from the rule's source comes + // back round (or never ends); Path lists the URLs of the loop. + Loop bool + // Chain is true when the rule is one hop of a longer chain; Path lists + // it from its first source to Final, Hops is its length and Source the + // rule that should point straight to Final. + Chain bool + Path []string + Hops int + Source string + Final string +} + +// PathText joins the check's path for the notices: "A → B → C". +func (c RedirectCheck) PathText() string { return strings.Join(c.Path, " → ") } + +// LoopError refuses a redirect that would create a loop (D-19). +type LoopError struct{ Check RedirectCheck } + +func (e *LoopError) Error() string { + return "golem15.seo: redirect would create a loop: " + e.Check.PathText() +} + +// ProblemsError refuses a redirect with field problems. +type ProblemsError struct{ Problems map[string][]models.Problem } + +func (e *ProblemsError) Error() string { + keys := make([]string, 0, len(e.Problems)) + for field, list := range e.Problems { + for _, p := range list { + keys = append(keys, field+": "+p.Key) + } + } + return "golem15.seo: redirect refused: " + strings.Join(keys, "; ") +} + +// redirectProbe returns a URL the rule's source matches: the source of an +// exact rule, a pattern with every * replaced by a probe token, or a string +// built from a regex's literals when one can be built (RESEARCH Pitfall +// 10). ok is false when no probe could be built. +func redirectProbe(r models.Redirect) (path, rawQuery string, ok bool) { + var probe string + switch r.Match { + case models.RedirectExact: + probe = r.Source + case models.RedirectPattern: + probe = strings.ReplaceAll(r.Source, "*", probeToken) + case models.RedirectRegex: + re, err := syntax.Parse(r.Source, syntax.Perl) + if err != nil { + return "", "", false + } + var b strings.Builder + if !writeProbe(&b, re.Simplify(), 0) { + return "", "", false + } + probe = b.String() + if expr := sourceExpr(r); expr == nil || !expr.MatchString(probe) || strings.Contains(probe, "?") { + return "", "", false + } + return probe, "", strings.HasPrefix(probe, "/") + default: + return "", "", false + } + p, q, _ := strings.Cut(probe, "?") + return p, q, strings.HasPrefix(p, "/") +} + +// writeProbe writes a shortest string the regex node matches: literals, +// the first alternative, the minimum of every repeat, the first rune of a +// class. ok is false for a node it cannot build. +func writeProbe(b *strings.Builder, re *syntax.Regexp, depth int) bool { + if depth > 100 || b.Len() > 2048 { + return false + } + switch re.Op { + case syntax.OpEmptyMatch, syntax.OpBeginLine, syntax.OpEndLine, syntax.OpBeginText, syntax.OpEndText, + syntax.OpWordBoundary, syntax.OpNoWordBoundary: + return true + case syntax.OpLiteral: + for _, r := range re.Rune { + b.WriteRune(r) + } + return true + case syntax.OpCharClass: + if len(re.Rune) < 2 { + return false + } + // Prefer a letter or digit, so a probe stays a plain path segment. + pick := re.Rune[0] + for _, c := range []rune{'a', '0', 'A'} { + if classHas(re.Rune, c) { + pick = c + break + } + } + if !utf8.ValidRune(pick) { + return false + } + b.WriteRune(pick) + return true + case syntax.OpAnyChar, syntax.OpAnyCharNotNL: + b.WriteString("a") + return true + case syntax.OpCapture: + return writeProbe(b, re.Sub[0], depth+1) + case syntax.OpConcat: + for _, sub := range re.Sub { + if !writeProbe(b, sub, depth+1) { + return false + } + } + return true + case syntax.OpAlternate: + return writeProbe(b, re.Sub[0], depth+1) + case syntax.OpStar, syntax.OpQuest: + return true + case syntax.OpPlus: + return writeProbe(b, re.Sub[0], depth+1) + case syntax.OpRepeat: + for i := 0; i < re.Min; i++ { + if !writeProbe(b, re.Sub[0], depth+1) { + return false + } + } + return true + } + return false +} + +// classHas reports whether a character class's ranges hold c. +func classHas(ranges []rune, c rune) bool { + for i := 0; i+1 < len(ranges); i += 2 { + if ranges[i] <= c && c <= ranges[i+1] { + return true + } + } + return false +} + +// CheckRedirect simulates r against the stored rules in db with r in +// place (P-19): a loop when following redirects from a URL r's source +// matches comes back round or does not end within MaxRedirectHops, a +// chain when it takes more than one hop or when another rule's target +// leads into r. A 410 rule ends every chain and is never a loop. +func (s *Service) CheckRedirect(ctx context.Context, db *gorm.DB, r models.Redirect) (RedirectCheck, error) { + var check RedirectCheck + if r.Status == models.Status410 { + return check, nil + } + rules, err := LoadRedirects(db.WithContext(ctx)) + if err != nil { + return check, err + } + if r.ID == 0 { + r.ID = candidateID + } + placed := false + for i := range rules { + if rules[i].ID == r.ID { + rules[i], placed = r, true + } + } + if !placed { + rules = append(rules, r) + } + set := NewRedirectSet(rules) + site := s.SiteURL() + path, query, ok := redirectProbe(r) + if ok { + ch, err := set.Follow(site, path, query, MaxRedirectHops) + if err != nil { + return check, err + } + if ch.Loop { + check.Loop, check.Path = true, displayPath(r, ch) + return check, nil + } + if len(ch.Hops) > 1 && ch.Hops[0].Rule.ID == r.ID { + check.Chain, check.Path, check.Hops = true, displayPath(r, ch), len(ch.Hops) + check.Source, check.Final = r.Source, displayURL(site, ch.Final) + return check, nil + } + } + // A rule whose target leads into r makes r the second hop of a chain. + for _, other := range rules { + if other.ID == r.ID || other.Status == models.Status410 { + continue + } + next, nextQuery, internal := internalTarget(site, other.Target) + if !internal || strings.ContainsAny(other.Target, "*$") { + continue + } + hit, matched, err := set.Match(next, nextQuery) + if err != nil { + return check, err + } + if !matched || hit.Rule.ID != r.ID { + continue + } + ch, err := set.Follow(site, next, nextQuery, MaxRedirectHops) + if err != nil || ch.Loop { + return check, err + } + check.Chain, check.Hops = true, len(ch.Hops)+1 + check.Path = append([]string{other.Source}, displayPath(r, ch)...) + check.Source, check.Final = other.Source, displayURL(site, ch.Final) + return check, nil + } + return check, nil +} + +// displayPath lists a chain's URLs for a notice, the source of r first. +func displayPath(r models.Redirect, ch Chain) []string { + out := make([]string, 0, len(ch.Visited)) + for i, v := range ch.Visited { + if i == 0 && r.Match != models.RedirectExact && len(ch.Hops) > 0 && ch.Hops[0].Rule.ID == r.ID { + v = r.Source + } + out = append(out, strings.ReplaceAll(v, probeToken, "*")) + } + if len(ch.Hops) > 0 && ch.Final != "" { + if last := out[len(out)-1]; last != ch.Final { + if _, _, internal := internalTarget("", ch.Final); !internal { + out = append(out, ch.Final) + } + } + } + return out +} + +// displayURL shows an absolute URL on the site as its path. +func displayURL(site, u string) string { + if site != "" && strings.HasPrefix(u, site+"/") { + return strings.TrimPrefix(u, site) + } + return u +} + +// SaveRedirect validates and stores r inside tx, the caller's transaction, +// and records it in history (P-18). It refuses field problems with a +// *ProblemsError and a loop with a *LoopError; the caller rolls back. The +// check answers a chain, which is saved (D-19). +func (s *Service) SaveRedirect(ctx context.Context, tx *gorm.DB, r *models.Redirect) (RedirectCheck, error) { + if r == nil { + return RedirectCheck{}, errors.New("golem15.seo: no redirect") + } + r.Normalize() + if problems := r.Problems(tx); len(problems) > 0 { + return RedirectCheck{}, &ProblemsError{Problems: problems} + } + check, err := s.CheckRedirect(ctx, tx, *r) + if err != nil { + return check, err + } + if check.Loop { + return check, &LoopError{Check: check} + } + now := time.Now().UTC() + var before map[string]any + if r.ID == 0 { + r.CreatedAt, r.UpdatedAt = &now, &now + if err := tx.Create(r).Error; err != nil { + return check, err + } + } else { + var old models.Redirect + if err := tx.Where("id = ?", r.ID).First(&old).Error; err != nil { + return check, err + } + before = RedirectValues(old) + r.UpdatedAt = &now + if err := tx.Save(r).Error; err != nil { + return check, err + } + } + return check, s.recordRedirect(ctx, tx, *r, before, RedirectValues(*r)) +} + +// DeleteRedirect deletes r inside tx and records it in history. +func (s *Service) DeleteRedirect(ctx context.Context, tx *gorm.DB, r models.Redirect) error { + return s.deleteRedirect(ctx, tx, r) +} + +// LoopMessage is the redirects.loop refusal in the request locale. +func (s *Service) LoopMessage(ctx context.Context, check RedirectCheck) string { + return s.PhraseCtx(ctx, "golem15.seo::lang.redirects.loop", map[string]string{"path": check.PathText()}) +} + +// ChainNotice is the redirects.chain_notice of a chain check. +func ChainNotice(check RedirectCheck) []pact.FormFieldNotice { + if !check.Chain { + return nil + } + return []pact.FormFieldNotice{{ + Message: "golem15.seo::lang.redirects.chain_notice", + Params: map[string]string{ + "path": check.PathText(), + "count": strconv.Itoa(check.Hops), + "source": check.Source, + "final": check.Final, + }, + }} +} + +// CoveringRedirect returns the rule that answers url now (a redirect or a +// 410), nil when none does: the 404 log's Resolved state. +func CoveringRedirect(set *RedirectSet, url string) (*models.Redirect, error) { + path, query, _ := strings.Cut(url, "?") + hit, ok, err := set.Match(path, query) + if err != nil || !ok { + return nil, err + } + return &hit.Rule, nil +} diff --git a/classes/redirects.go b/classes/redirects.go index 491bd9a..93c5deb 100644 --- a/classes/redirects.go +++ b/classes/redirects.go @@ -1,29 +1,539 @@ package classes import ( + "net/url" + "regexp" + "sort" + "strconv" "strings" + "sync" "git.golem15.com/golem15/sm-seo-plugin/models" + "git.golem15.com/golem15/summercms/modules/diary" "gorm.io/gorm" ) +// log is the classes' logger (component=seo). +var log = diary.For("seo") + // The resolve actions of the redirect stage. const ( ActionRedirect = "redirect" ActionGone = "gone" ) -// exactRedirect returns the exact redirect whose source is path, nil when -// there is none. -func exactRedirect(db *gorm.DB, path string) (*models.Redirect, error) { +// MaxRedirectHops caps how many redirects the resolver follows for one +// URL, and how far the save-time simulation looks (P-19, P-22). +const MaxRedirectHops = 10 + +// MaxRegexSource caps a regex source (T-32-17). +const MaxRegexSource = 512 + +// probeToken stands in for * when a pattern source is simulated. +const probeToken = "seoprobe" + +// compiledRedirect is a pattern or regex rule with its expression. +type compiledRedirect struct { + rule models.Redirect + re *regexp.Regexp + prefix int +} + +// regexCache keeps the compiled expressions of pattern and regex sources, +// so each distinct source is compiled once (stdlib RE2: linear time on any +// input). +var regexCache sync.Map + +// sourceExpr returns the expression of a pattern or regex source, nil +// when it does not compile. +func sourceExpr(r models.Redirect) *regexp.Regexp { + key := r.Match + "\x00" + strconv.FormatBool(r.IgnoreCase) + "\x00" + strconv.FormatBool(r.TrailingSlash) + "\x00" + r.Source + if re, ok := regexCache.Load(key); ok { + return re.(*regexp.Regexp) + } + var expr string + switch r.Match { + case models.RedirectPattern: + expr = WildcardExpr(r.Source, r.TrailingSlash) + case models.RedirectRegex: + expr = r.Source + default: + return nil + } + if r.IgnoreCase { + expr = "(?i)" + expr + } + re, err := regexp.Compile(expr) + if err != nil { + return nil + } + regexCache.Store(key, re) + return re +} + +// WildcardExpr turns a pattern source into an anchored expression: every * +// matches any text (a capture), everything else is literal. With +// trailingSlash a source that does not end in * or / also matches with a +// trailing slash, and one that ends in / also without it. +func WildcardExpr(source string, trailingSlash bool) string { + src := source + optional := false + if trailingSlash && !strings.Contains(src, "?") && !strings.HasSuffix(src, "*") && src != "/" { + src = strings.TrimSuffix(src, "/") + optional = true + } + expr := "^" + strings.ReplaceAll(regexp.QuoteMeta(src), `\*`, `(.*)`) + if optional { + expr += "/?" + } + return expr + "$" +} + +// literalPrefix is the length of a pattern source before its first *. +func literalPrefix(source string) int { + if i := strings.Index(source, "*"); i >= 0 { + return i + } + return len(source) +} + +// trimSlash drops one trailing slash, except from the root. +func trimSlash(p string) string { + if len(p) > 1 && strings.HasSuffix(p, "/") { + return strings.TrimSuffix(p, "/") + } + return p +} + +// toggleSlash adds a trailing slash, or drops it; the root stays. +func toggleSlash(p string) string { + if p == "/" { + return p + } + if strings.HasSuffix(p, "/") { + return strings.TrimSuffix(p, "/") + } + return p + "/" +} + +// exactKey is the lookup key of an exact source: lowercased, without a +// trailing slash on its path, with its query as written. +func exactKey(source string) string { + p, q, hasQuery := strings.Cut(source, "?") + key := strings.ToLower(trimSlash(p)) + if hasQuery { + key += "?" + q + } + return key +} + +// exactKeys are the lookup keys a request path and query may match. +func exactKeys(path, rawQuery string) []string { + keys := []string{strings.ToLower(trimSlash(path))} + if rawQuery != "" { + keys = append(keys, keys[0]+"?"+rawQuery) + } + return keys +} + +// exactMatches reports whether an exact rule matches the request: the +// source's path with the rule's case and trailing-slash options, and, when +// the source has a query, the request's query exactly (Q-9). +func exactMatches(r models.Redirect, path, rawQuery string) bool { + srcPath, srcQuery, hasQuery := strings.Cut(r.Source, "?") + if hasQuery && srcQuery != rawQuery { + return false + } + eq := func(a, b string) bool { + if r.IgnoreCase { + return strings.EqualFold(a, b) + } + return a == b + } + if eq(srcPath, path) { + return true + } + return r.TrailingSlash && eq(trimSlash(srcPath), trimSlash(path)) +} + +// RedirectSet is a set of redirect rules ready to match request paths: +// exact sources by key, then patterns by the length of their literal +// prefix (longest first, then id), then regex sources by id (D-17). +type RedirectSet struct { + exact map[string][]models.Redirect + patterns []*compiledRedirect + regexes []*compiledRedirect + // fetch loads the exact rules of more keys on demand (the resolver + // reads only the rows a path can match); nil when every rule is in + // memory. + fetch func(keys []string) ([]models.Redirect, error) + fetched map[string]bool +} + +// NewRedirectSet compiles rules into a set. A pattern or regex source that +// does not compile is left out. +func NewRedirectSet(rules []models.Redirect) *RedirectSet { + s := &RedirectSet{exact: map[string][]models.Redirect{}, fetched: map[string]bool{}} + s.add(rules) + return s +} + +// add puts rules into the set, once per id. +func (s *RedirectSet) add(rules []models.Redirect) { + for _, r := range rules { + switch r.Match { + case models.RedirectPattern, models.RedirectRegex: + re := sourceExpr(r) + if re == nil { + continue + } + c := &compiledRedirect{rule: r, re: re, prefix: literalPrefix(r.Source)} + if r.Match == models.RedirectPattern { + s.patterns = append(s.patterns, c) + } else { + s.regexes = append(s.regexes, c) + } + default: + key := exactKey(r.Source) + dup := false + for _, have := range s.exact[key] { + dup = dup || have.ID == r.ID + } + if !dup { + s.exact[key] = append(s.exact[key], r) + } + } + } + sort.SliceStable(s.patterns, func(i, j int) bool { + if s.patterns[i].prefix != s.patterns[j].prefix { + return s.patterns[i].prefix > s.patterns[j].prefix + } + return s.patterns[i].rule.ID < s.patterns[j].rule.ID + }) + sort.SliceStable(s.regexes, func(i, j int) bool { return s.regexes[i].rule.ID < s.regexes[j].rule.ID }) + for key := range s.exact { + rows := s.exact[key] + sort.SliceStable(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID }) + } +} + +// LoadRedirects returns every redirect rule. +func LoadRedirects(db *gorm.DB) ([]models.Redirect, error) { var rows []models.Redirect - if err := db.Where("match = ? AND source = ?", models.RedirectExact, path).Limit(1).Find(&rows).Error; err != nil { + err := db.Order("id ASC").Find(&rows).Error + return rows, err +} + +// LoadRedirectSet reads the rule set for the resolver: every pattern and +// regex rule now, exact rules per path as the set needs them (indexed on +// lower(source)). +func LoadRedirectSet(db *gorm.DB) (*RedirectSet, error) { + var rows []models.Redirect + if err := db.Where("match <> ?", models.RedirectExact).Order("id ASC").Find(&rows).Error; err != nil { return nil, err } - if len(rows) == 0 { - return nil, nil + s := NewRedirectSet(rows) + s.fetch = func(keys []string) ([]models.Redirect, error) { + var out []models.Redirect + err := db.Where("match = ? AND lower(source) IN ?", models.RedirectExact, keys).Order("id ASC").Find(&out).Error + return out, err } - return &rows[0], nil + return s, nil +} + +// ensure loads the exact rules of keys the set has not read yet. +func (s *RedirectSet) ensure(keys []string) error { + if s.fetch == nil { + return nil + } + var missing []string + for _, k := range keys { + if !s.fetched[k] { + missing = append(missing, k) + s.fetched[k] = true + } + } + if len(missing) == 0 { + return nil + } + rows, err := s.fetch(missing) + if err != nil { + return err + } + s.add(rows) + return nil +} + +// RedirectHit is a rule that matched a request and the target it sends +// the request to: a path or an absolute URL, "" for 410 Gone. +type RedirectHit struct { + Rule models.Redirect + Target string +} + +// Match returns the first rule that matches path and rawQuery, in the +// D-17 order, with its target. A rule whose substituted target would leave +// the target's own host (an open redirect) does not match. +func (s *RedirectSet) Match(path, rawQuery string) (RedirectHit, bool, error) { + if err := s.ensure(exactKeys(path, rawQuery)); err != nil { + return RedirectHit{}, false, err + } + for _, key := range exactKeys(path, rawQuery) { + for _, r := range s.exact[key] { + if exactMatches(r, path, rawQuery) { + if hit, ok := finishHit(r, r.Target, rawQuery, false); ok { + return hit, true, nil + } + } + } + } + full := path + if rawQuery != "" { + full = path + "?" + rawQuery + } + for _, c := range s.patterns { + subject := path + if strings.Contains(c.rule.Source, "?") { + subject = full + } + m := c.re.FindStringSubmatch(subject) + if m == nil { + continue + } + target := expandStars(c.rule.Target, m[1:]) + if hit, ok := finishHit(c.rule, target, rawQuery, strings.Contains(c.rule.Source, "?")); ok { + return hit, true, nil + } + } + for _, c := range s.regexes { + m := c.re.FindStringSubmatch(path) + if m == nil && c.rule.TrailingSlash { + m = c.re.FindStringSubmatch(toggleSlash(path)) + } + if m == nil { + continue + } + target := expandGroups(c.re, c.rule.Target, m) + if hit, ok := finishHit(c.rule, target, rawQuery, false); ok { + return hit, true, nil + } + } + return RedirectHit{}, false, nil +} + +// finishHit checks a substituted target against its literal and passes +// the request's query on when the rule asks for it. ok is false when the +// target would point to another host than its literal names (T-32-16). +func finishHit(r models.Redirect, target, rawQuery string, queryMatched bool) (RedirectHit, bool) { + if r.Status == models.Status410 { + return RedirectHit{Rule: r}, true + } + safe, ok := SafeTarget(r.Target, target) + if !ok { + return RedirectHit{}, false + } + if r.Query == models.QueryPass && rawQuery != "" && !queryMatched && !strings.Contains(r.Source, "?") { + sep := "?" + if strings.Contains(safe, "?") { + sep = "&" + } + safe += sep + rawQuery + } + return RedirectHit{Rule: r, Target: safe}, true +} + +// SafeTarget checks a substituted target against the rule's literal +// target (T-32-16): a relative literal must stay a path on this site +// (repeated leading slashes collapse, so a capture cannot make it +// protocol-relative), and an absolute literal must keep its scheme and +// host. ok is false otherwise. +func SafeTarget(literal, target string) (string, bool) { + target = strings.TrimSpace(target) + if target == "" { + return "", false + } + if lu, err := url.Parse(literal); err == nil && lu.IsAbs() { + u, err := url.Parse(target) + base, berr := url.Parse(regexpGroup.ReplaceAllString(strings.ReplaceAll(literal, "*", ""), "")) + if err != nil || berr != nil || !u.IsAbs() || u.Host == "" || + !strings.EqualFold(u.Scheme, base.Scheme) || !strings.EqualFold(u.Host, base.Host) { + return "", false + } + return target, true + } + if !strings.HasPrefix(target, "/") && !strings.HasPrefix(target, `\`) { + return "", false + } + target = "/" + strings.TrimLeft(target, `/\`) + u, err := url.Parse(target) + if err != nil || u.IsAbs() || u.Host != "" { + return "", false + } + return target, true +} + +// regexpGroup finds $1, ${1} and ${name} references in a target. +var regexpGroup = regexp.MustCompile(`\$(\d+|\{[A-Za-z0-9_]+\})`) + +// expandStars replaces each * of a pattern target with the matching +// capture of the source, in order; a * without a capture becomes "". +func expandStars(target string, captures []string) string { + if !strings.Contains(target, "*") { + return target + } + var b strings.Builder + i := 0 + for _, part := range strings.SplitAfter(target, "*") { + if !strings.HasSuffix(part, "*") { + b.WriteString(part) + continue + } + b.WriteString(strings.TrimSuffix(part, "*")) + if i < len(captures) { + b.WriteString(escapeCapture(captures[i])) + } + i++ + } + return b.String() +} + +// expandGroups replaces $1, ${1} and ${name} in a regex target with the +// escaped captures of m; an unknown group becomes "". +func expandGroups(re *regexp.Regexp, target string, m []string) string { + return regexpGroup.ReplaceAllStringFunc(target, func(ref string) string { + name := strings.Trim(strings.TrimPrefix(ref, "$"), "{}") + idx, err := strconv.Atoi(name) + if err != nil { + idx = re.SubexpIndex(name) + } + if idx <= 0 || idx >= len(m) { + return "" + } + return escapeCapture(m[idx]) + }) +} + +// escapeCapture percent-encodes a captured piece of a decoded path for a +// target's path: unreserved characters, "/" and the sub-delimiters stay, +// everything else (?, #, \, %, spaces, non-ASCII) is encoded, so a capture +// can neither start a query or fragment nor carry a backslash. +func escapeCapture(s string) string { + const keep = "-._~/!$&'()*+,;=:@" + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.IndexByte(keep, c) >= 0 { + b.WriteByte(c) + continue + } + b.WriteString("%" + strings.ToUpper(strconv.FormatUint(uint64(c)>>4, 16)) + strings.ToUpper(strconv.FormatUint(uint64(c)&15, 16))) + } + return b.String() +} + +// Chain is the result of following redirects from one URL. +type Chain struct { + // Hops are the rules that fired, in order. + Hops []RedirectHit + // Visited are the URLs the chain went through: the start, then each + // internal target (path with query). + Visited []string + // Final is the last target: a path or an absolute URL. + Final string + // Loop is true when a URL or a rule came round again, or the chain + // reached MaxRedirectHops. + Loop bool + // Gone is true when the chain ends on a 410 rule. + Gone bool + // Status is 302 when any hop is temporary, else 301. + Status int +} + +// Follow follows the set from path and rawQuery, at most max hops. site is +// the site URL: an absolute target on it is followed like a path. +func (s *RedirectSet) Follow(site, path, rawQuery string, max int) (Chain, error) { + ch := Chain{Status: models.Status301} + cur, query := path, rawQuery + seen := map[string]bool{joinQuery(cur, query): true} + used := map[uint]bool{} + ch.Visited = append(ch.Visited, joinQuery(cur, query)) + for { + hit, ok, err := s.Match(cur, query) + if err != nil { + return ch, err + } + if !ok { + return ch, nil + } + if used[hit.Rule.ID] { + ch.Loop = true + return ch, nil + } + used[hit.Rule.ID] = true + ch.Hops = append(ch.Hops, hit) + if hit.Rule.Status == models.Status302 { + ch.Status = models.Status302 + } + if hit.Rule.Status == models.Status410 { + ch.Gone = true + return ch, nil + } + ch.Final = hit.Target + next, nextQuery, internal := internalTarget(site, hit.Target) + if !internal { + return ch, nil + } + key := joinQuery(next, nextQuery) + ch.Visited = append(ch.Visited, key) + if seen[key] || len(ch.Hops) >= max { + ch.Loop = true + return ch, nil + } + seen[key] = true + cur, query = next, nextQuery + } +} + +// joinQuery is path with its query. +func joinQuery(path, rawQuery string) string { + if rawQuery == "" { + return path + } + return path + "?" + rawQuery +} + +// internalTarget returns the decoded path and the query of a target on +// this site: a path, or an absolute URL whose origin is the site URL. +func internalTarget(site, target string) (string, string, bool) { + u, err := url.Parse(target) + if err != nil { + return "", "", false + } + if u.IsAbs() { + su, err := url.Parse(site) + if site == "" || err != nil || !strings.EqualFold(su.Host, u.Host) || !strings.EqualFold(su.Scheme, u.Scheme) { + return "", "", false + } + } else if !strings.HasPrefix(target, "/") { + return "", "", false + } + p := u.Path + if p == "" { + p = "/" + } + return p, u.RawQuery, true +} + +// redirectHopsLogged keeps the URLs whose runtime redirect cycle was +// logged, so each is logged once. +var redirectHopsLogged sync.Map + +// logRedirectCycle logs a redirect cycle met at runtime, once per URL. +func logRedirectCycle(url string, ch Chain) { + if _, seen := redirectHopsLogged.LoadOrStore(url, true); seen { + return + } + log.Warn("golem15.seo: redirect cycle, answering not_found", "url", url, "path", strings.Join(ch.Visited, " → ")) } // RedirectValues are a redirect's values for history. diff --git a/classes/resolver.go b/classes/resolver.go index c895efb..8afff0e 100644 --- a/classes/resolver.go +++ b/classes/resolver.go @@ -360,12 +360,16 @@ func (r *Resolver) Resolve(ctx context.Context, rawURL string) (Result, error) { if err != nil { return Result{}, err } - redirect, err := exactRedirect(db, path) + set, err := LoadRedirectSet(db) if err != nil { return Result{}, err } - if redirect != nil { - return r.redirectAnswer(svc.SiteURL()+path, *redirect), nil + chain, err := set.Follow(svc.SiteURL(), path, rawQueryOf(rawURL), MaxRedirectHops) + if err != nil { + return Result{}, err + } + if len(chain.Hops) > 0 { + return r.redirectAnswer(svc.SiteURL()+joinQuery(path, rawQueryOf(rawURL)), chain), nil } key, match, rec, err := r.lookup(ctx, path) if err != nil { @@ -567,16 +571,21 @@ func (r *Resolver) resolveAt(ctx context.Context, db *gorm.DB, settings models.S return res, nil } -// redirectAnswer is the answer of a matched redirect: gone for a 410, -// else redirect with the rule's status and its target absolute on the -// site URL. -func (r *Resolver) redirectAnswer(self string, rule models.Redirect) Result { +// 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. +func (r *Resolver) redirectAnswer(self string, ch Chain) Result { res := Result{URL: self, Staging: r.Service.Staging(), Sources: map[string]string{}} - if rule.Status == models.Status410 { + switch { + case ch.Loop: + logRedirectCycle(self, ch) + res.Action, res.Status = ActionNotFound, 404 + case ch.Gone: res.Action, res.Status = ActionGone, models.Status410 - return res + default: + res.Action, res.Status, res.Location = ActionRedirect, ch.Status, AbsoluteURL(r.Service.SiteURL(), ch.Final) } - res.Action, res.Status, res.Location = ActionRedirect, rule.Status, AbsoluteURL(r.Service.SiteURL(), rule.Target) return res } diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index d4f3c8e..50791d5 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -191,3 +191,11 @@ snippet: invalid: The preview request is not valid. redirects: auto_created: The address changed, so a 301 from “:old” to “:new” was created. :count earlier redirects now point straight to the new address. + loop: "This redirect would create a loop: :path. Nothing was saved." + chain_notice: "Saved, but this redirect is part of a chain: :path. Visitors take :count hops. Point “:source” straight to “:final”." + target_required: "Enter the address to redirect to, or choose 410 Gone." + target_gone: "A 410 Gone rule sends visitors nowhere. Clear the target, or choose 301 or 302." + target_invalid: "Enter a path starting with / or a full http(s) address." + regex_invalid: "This is not a valid regular expression: :error" + source_invalid: "Start the address with /. Use * only in a pattern." + source_taken: "Another redirect already uses this address and match." diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 07a0a10..72188fe 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -195,3 +195,11 @@ snippet: invalid: Żądanie podglądu jest nieprawidłowe. redirects: auto_created: Adres się zmienił, więc utworzono przekierowanie 301 z „:old” na „:new”. Wcześniejsze przekierowania (:count) prowadzą teraz bezpośrednio na nowy adres. + loop: "To przekierowanie utworzyłoby pętlę: :path. Nic nie zapisano." + chain_notice: "Zapisano, ale to przekierowanie jest częścią łańcucha: :path. Odwiedzający przechodzą :count przekierowania. Ustaw „:source” bezpośrednio na „:final”." + target_required: "Wpisz adres docelowy lub wybierz 410 Usunięto." + target_gone: "Reguła 410 Usunięto nikogo nie przekierowuje. Wyczyść adres docelowy lub wybierz 301 albo 302." + target_invalid: "Wpisz ścieżkę zaczynającą się od / lub pełny adres http(s)." + regex_invalid: "To nie jest poprawne wyrażenie regularne: :error" + source_invalid: "Zacznij adres od /. Używaj * tylko we wzorcu." + source_taken: "Inne przekierowanie już używa tego adresu i dopasowania." diff --git a/models/redirect.go b/models/redirect.go index b2bbf92..18c7fee 100644 --- a/models/redirect.go +++ b/models/redirect.go @@ -1,6 +1,13 @@ package models -import "time" +import ( + "net/url" + "regexp" + "strings" + "time" + + "gorm.io/gorm" +) // The match kinds of a redirect source (D-17). const ( @@ -64,6 +71,121 @@ func NewRedirect() Redirect { return Redirect{Match: RedirectExact, Status: Status301, Query: QueryIgnore, TrailingSlash: true, Origin: OriginManual} } +// The phrase keys of the redirect refusals. +const ( + errRedirectSourceInvalid = "golem15.seo::lang.redirects.source_invalid" + errRedirectSourceTaken = "golem15.seo::lang.redirects.source_taken" + errRedirectRegexInvalid = "golem15.seo::lang.redirects.regex_invalid" + errRedirectTargetRequired = "golem15.seo::lang.redirects.target_required" + errRedirectTargetGone = "golem15.seo::lang.redirects.target_gone" + errRedirectTargetInvalid = "golem15.seo::lang.redirects.target_invalid" +) + +// MaxRedirectRegex caps a regex source in characters (T-32-17); other +// sources and targets are capped at MaxRedirectURL bytes. +const ( + MaxRedirectRegex = 512 + MaxRedirectURL = 2048 +) + +// Normalize trims the source and target and replaces an unknown match, +// status or query option with the default. +func (r *Redirect) Normalize() { + r.Source = strings.TrimSpace(r.Source) + r.Target = strings.TrimSpace(r.Target) + r.Note = strings.TrimSpace(r.Note) + switch r.Match { + case RedirectExact, RedirectPattern, RedirectRegex: + default: + r.Match = RedirectExact + } + switch r.Status { + case Status301, Status302, Status410: + default: + r.Status = Status301 + } + if r.Query != QueryPass { + r.Query = QueryIgnore + } + if r.Origin == "" { + r.Origin = OriginManual + } +} + +// Fillable lists the form's columns. +func (Redirect) Fillable() []string { + return []string{"source", "match", "target", "status", "query", "ignore_case", "trailing_slash", "note"} +} + +// Rules requires the source; its form is checked in Problems. +func (Redirect) Rules() map[string]string { return map[string]string{"source": "required"} } + +// Problems lists the refusals of a redirect per field (D-16, D-17): a +// source that does not start with / (a regex may start with ^/), a * in an +// exact source, a regex that does not compile or is longer than 512 +// characters, a source and match another redirect has, a missing target +// for 301/302, a target on a 410, and a target that is neither a path nor +// an http(s) URL. db is the save transaction; nil skips the uniqueness +// check. +func (r Redirect) Problems(db *gorm.DB) map[string][]Problem { + out := map[string][]Problem{} + src := r.Source + switch { + case src == "" || len(src) > MaxRedirectURL || strings.ContainsAny(src, " \t\r\n"): + out["source"] = []Problem{{Key: errRedirectSourceInvalid}} + case r.Match == RedirectRegex: + if !strings.HasPrefix(strings.TrimPrefix(src, "^"), "/") { + out["source"] = []Problem{{Key: errRedirectSourceInvalid}} + } else if n := len([]rune(src)); n > MaxRedirectRegex { + out["source"] = []Problem{{Key: errRedirectRegexInvalid, Params: map[string]string{"error": "longer than 512 characters"}}} + } else if _, err := regexp.Compile(src); err != nil { + out["source"] = []Problem{{Key: errRedirectRegexInvalid, Params: map[string]string{"error": regexError(err)}}} + } + case !strings.HasPrefix(src, "/") || strings.HasPrefix(src, "//"): + out["source"] = []Problem{{Key: errRedirectSourceInvalid}} + case r.Match == RedirectExact && strings.Contains(src, "*"): + out["source"] = []Problem{{Key: errRedirectSourceInvalid}} + } + if _, bad := out["source"]; !bad && db != nil { + var count int64 + q := db.Model(&Redirect{}).Where("source = ? AND match = ?", src, r.Match) + if r.ID != 0 { + q = q.Where("id <> ?", r.ID) + } + if err := q.Count(&count).Error; err == nil && count > 0 { + out["source"] = []Problem{{Key: errRedirectSourceTaken}} + } + } + switch { + case r.Status == Status410 && r.Target != "": + out["target"] = []Problem{{Key: errRedirectTargetGone}} + case r.Status != Status410 && r.Target == "": + out["target"] = []Problem{{Key: errRedirectTargetRequired}} + case r.Status != Status410 && !validTarget(r.Target): + out["target"] = []Problem{{Key: errRedirectTargetInvalid}} + } + return out +} + +// validTarget reports whether a target literal is a path ("/x", not +// "//x") or an absolute http(s) URL with a host, without whitespace. +func validTarget(t string) bool { + if len(t) > MaxRedirectURL || strings.ContainsFunc(t, func(r rune) bool { return r <= ' ' || r == 0x7f }) { + return false + } + if strings.HasPrefix(t, "/") { + return !strings.HasPrefix(t, "//") && !strings.HasPrefix(t, "/\\") + } + u, err := url.Parse(t) + return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" +} + +// regexError is the text of a regex compile error without the package +// prefix. +func regexError(err error) string { + return strings.TrimPrefix(err.Error(), "error parsing regexp: ") +} + func init() { Register(Redirect{}) } diff --git a/redirects_test.go b/redirects_test.go index b178291..72a4375 100644 --- a/redirects_test.go +++ b/redirects_test.go @@ -2,9 +2,15 @@ package seo_test import ( "encoding/json" + "errors" "net/http" + "sort" "strings" "testing" + + "git.golem15.com/golem15/sm-seo-plugin/classes" + "git.golem15.com/golem15/sm-seo-plugin/models" + "gorm.io/gorm" ) // redirectRow is a stored redirect as the tests read it. @@ -119,3 +125,199 @@ func TestSlugChange301(t *testing.T) { t.Fatalf("resolve /pages/x = %v, want one hop to /pages/b", got) } } + +// saveRedirect stores r through the service in its own transaction. +func (e *testEnv) saveRedirect(t *testing.T, r models.Redirect) (classes.RedirectCheck, error) { + t.Helper() + svc := e.seoPlugin(t).Service() + var check classes.RedirectCheck + err := e.db.Transaction(func(tx *gorm.DB) error { + c, err := svc.SaveRedirect(t.Context(), tx, &r) + check = c + return err + }) + return check, err +} + +// mustRedirect stores r and fails on a refusal. +func (e *testEnv) mustRedirect(t *testing.T, source, match, target string, status int, opts ...func(*models.Redirect)) classes.RedirectCheck { + t.Helper() + r := models.NewRedirect() + r.Source, r.Match, r.Target, r.Status = source, match, target, status + for _, o := range opts { + o(&r) + } + check, err := e.saveRedirect(t, r) + if err != nil { + t.Fatalf("save %s -> %s: %v", source, target, err) + } + return check +} + +// refusedOn asserts that saving r is refused with key on field. +func (e *testEnv) refusedOn(t *testing.T, r models.Redirect, field, key string) { + t.Helper() + _, err := e.saveRedirect(t, r) + var problems *classes.ProblemsError + if !errors.As(err, &problems) { + t.Fatalf("save %+v: err = %v, want a refusal on %s", r, err, field) + } + for _, p := range problems.Problems[field] { + if p.Key == key { + return + } + } + t.Fatalf("save %+v: problems = %+v, want %s on %s", r, problems.Problems, key, field) +} + +// expectRedirect asserts that target resolves to a redirect to location. +func (e *testEnv) expectRedirect(t *testing.T, target string, status int, location string) { + t.Helper() + got := e.resolve(t, target) + if got["action"] != "redirect" || got["status"] != float64(status) || got["location"] != location { + t.Fatalf("resolve %s = %v, want redirect %d to %s", target, got, status, location) + } +} + +// expectAction asserts the resolve action of target. +func (e *testEnv) expectAction(t *testing.T, target, action string) map[string]any { + t.Helper() + got := e.resolve(t, target) + if got["action"] != action { + t.Fatalf("resolve %s = %v, want %s", target, got, action) + } + return got +} + +// TestRedirectEngine checks the redirect engine through the service and +// the resolve answer (D-16, D-17, D-19, T-32-16, T-32-17): matching +// options, order, statuses, refusals, loops, chains and the back-and-forth +// address change. +func TestRedirectEngine(t *testing.T) { + env := bootEnv(t) + svc := env.seoPlugin(t).Service() + ignoreCase := func(r *models.Redirect) { r.IgnoreCase = true } + noSlash := func(r *models.Redirect) { r.TrailingSlash = false } + pass := func(r *models.Redirect) { r.Query = models.QueryPass } + + env.mustRedirect(t, "/old", "exact", "/new", 301) + env.expectRedirect(t, "/old", 301, siteURL+"/new") + env.expectRedirect(t, "/old/", 301, siteURL+"/new") + env.expectRedirect(t, "/old?a=1", 301, siteURL+"/new") + env.expectAction(t, "/OLD", "not_found") + + env.mustRedirect(t, "/Case", "exact", "/case-new", 302, ignoreCase) + env.expectRedirect(t, "/CASE", 302, siteURL+"/case-new") + env.mustRedirect(t, "/strict", "exact", "/strict-new", 301, noSlash) + env.expectAction(t, "/strict/", "not_found") + + env.mustRedirect(t, "/p-old", "exact", "/p-new", 301, pass) + env.expectRedirect(t, "/p-old?a=1&b=2", 301, siteURL+"/p-new?a=1&b=2") + env.mustRedirect(t, "/q?id=5", "exact", "/q5", 301, pass) + env.expectRedirect(t, "/q?id=5", 301, siteURL+"/q5") + env.expectAction(t, "/q?id=6", "not_found") + + env.mustRedirect(t, "/w/*", "pattern", "/nw/*", 301) + env.expectRedirect(t, "/w/x/y", 301, siteURL+"/nw/x/y") + env.mustRedirect(t, `^/p/(\d+)$`, "regex", "/post/$1", 301) + env.expectRedirect(t, "/p/12", 301, siteURL+"/post/12") + env.expectAction(t, "/p/abc", "not_found") + + env.mustRedirect(t, "/w/exact", "exact", "/exact-wins", 301) + env.mustRedirect(t, `^/w/.*$`, "regex", "/regex-loses", 301) + env.expectRedirect(t, "/w/exact", 301, siteURL+"/exact-wins") + env.expectRedirect(t, "/w/z", 301, siteURL+"/nw/z") + + env.mustRedirect(t, "/gone", "exact", "", 410) + got := env.expectAction(t, "/gone", "gone") + if got["status"] != float64(410) || got["location"] != nil { + t.Fatalf("gone answer = %v, want 410 without a location", got) + } + + newRule := func(source, match, target string, status int) models.Redirect { + r := models.NewRedirect() + r.Source, r.Match, r.Target, r.Status = source, match, target, status + return r + } + env.refusedOn(t, newRule("/g2", "exact", "/x", 410), "target", "golem15.seo::lang.redirects.target_gone") + env.refusedOn(t, newRule("/g3", "exact", "", 301), "target", "golem15.seo::lang.redirects.target_required") + env.refusedOn(t, newRule("/g4", "exact", "elsewhere", 301), "target", "golem15.seo::lang.redirects.target_invalid") + env.refusedOn(t, newRule(`^/(`, "regex", "/x", 301), "source", "golem15.seo::lang.redirects.regex_invalid") + env.refusedOn(t, newRule("^/"+strings.Repeat("a", 520), "regex", "/x", 301), "source", "golem15.seo::lang.redirects.regex_invalid") + env.refusedOn(t, newRule("/old", "exact", "/other", 301), "source", "golem15.seo::lang.redirects.source_taken") + env.refusedOn(t, newRule("old", "exact", "/other", 301), "source", "golem15.seo::lang.redirects.source_invalid") + env.refusedOn(t, newRule("/a*", "exact", "/other", 301), "source", "golem15.seo::lang.redirects.source_invalid") + + // Loops are refused; chains are saved with a notice (D-19). + env.mustRedirect(t, "/la", "exact", "/lb", 301) + _, err := env.saveRedirect(t, newRule("/lb", "exact", "/la", 301)) + var loop *classes.LoopError + if !errors.As(err, &loop) || loop.Check.PathText() != "/lb → /la → /lb" { + t.Fatalf("B -> A after A -> B: err = %v, want a loop /lb → /la → /lb", err) + } + if !strings.Contains(svc.LoopMessage(t.Context(), loop.Check), "/lb → /la → /lb") { + t.Fatalf("loop message = %q", svc.LoopMessage(t.Context(), loop.Check)) + } + _, err = env.saveRedirect(t, newRule("/grow/*", "pattern", "/grow/new/*", 301)) + if !errors.As(err, &loop) { + t.Fatalf("/grow/* -> /grow/new/*: err = %v, want a loop (pattern probe)", err) + } + _, err = env.saveRedirect(t, newRule(`^/rx/(\d+)$`, "regex", "/rx/$1", 301)) + if !errors.As(err, &loop) { + t.Fatalf("regex onto itself: err = %v, want a loop (regex probe)", err) + } + env.mustRedirect(t, "/cb", "exact", "/cc", 301) + check := env.mustRedirect(t, "/ca", "exact", "/cb", 301) + if !check.Chain || check.Final != "/cc" || check.Hops != 2 || check.PathText() != "/ca → /cb → /cc" { + t.Fatalf("A -> B with B -> C: check = %+v, want a chain to /cc", check) + } + check = env.mustRedirect(t, "/cz", "exact", "/ca", 301) + if !check.Chain || check.Final != "/cc" || check.Hops != 3 { + t.Fatalf("Z -> A -> B -> C: check = %+v, want a 3-hop chain to /cc", check) + } + env.expectRedirect(t, "/cz", 301, siteURL+"/cc") + check = env.mustRedirect(t, "/lone", "exact", "/somewhere", 301) + if check.Chain || check.Loop { + t.Fatalf("a single redirect: check = %+v, want neither chain nor loop", check) + } + + // Slug back and forth (RESEARCH Pitfall 11): A→B, B→A, then A→C ends + // with exactly A→C and B→C. + for _, step := range [][2]string{{"/fa", "/fb"}, {"/fb", "/fa"}, {"/fa", "/fc"}} { + if err := env.db.Transaction(func(tx *gorm.DB) error { + _, err := svc.AddressChanged(t.Context(), tx, classes.AddressChange{Old: step[0], New: step[1]}) + return err + }); err != nil { + t.Fatal(err) + } + } + var back []string + for _, r := range env.redirects(t) { + if strings.HasPrefix(r.Source, "/f") { + back = append(back, r.Source+"->"+r.Target) + } + } + sort.Strings(back) + if strings.Join(back, ",") != "/fa->/fc,/fb->/fc" { + t.Fatalf("back and forth = %v, want exactly /fa->/fc and /fb->/fc", back) + } + + // A capture never turns an internal target into another host + // (T-32-16); an absolute literal keeps its own host. + env.mustRedirect(t, `^/r/(.*)$`, "regex", "/$1", 301) + got = env.resolve(t, "/r//evil.example/x") + if loc, _ := got["location"].(string); got["action"] != "redirect" || !strings.HasPrefix(loc, siteURL+"/") || strings.HasPrefix(loc, siteURL+"//") { + t.Fatalf("protocol-relative capture = %v, want a path on the site", got) + } + env.mustRedirect(t, `^/ext/(.*)$`, "regex", "https://partner.example/$1", 301) + env.expectRedirect(t, "/ext/x", 301, "https://partner.example/x") + env.seedRedirect(t, `^/ext2(.*)$`, "regex", "https://partner.example$1", 301) + if got := env.resolve(t, "/ext2@evil.example"); got["action"] == "redirect" { + t.Fatalf("capture into the host = %v, want no redirect", got) + } + env.mustRedirect(t, "/qp/*", "pattern", "/nq/*", 301, pass) + got = env.resolve(t, "/qp/a?next=//evil.example") + if loc, _ := got["location"].(string); !strings.HasPrefix(loc, siteURL+"/nq/a?") { + t.Fatalf("passed query = %v, want it appended to the query only", got) + } +} diff --git a/updates/202610270006_create_golem15_seo_redirects.go b/updates/202610270006_create_golem15_seo_redirects.go index 931c496..61f803d 100644 --- a/updates/202610270006_create_golem15_seo_redirects.go +++ b/updates/202610270006_create_golem15_seo_redirects.go @@ -7,12 +7,12 @@ import ( // createRedirects creates golem15_seo_redirects: 301, 302 and 410 rules // with exact, pattern and regex sources (D-16, D-17), one per source and -// match. +// match, indexed on the lowercased source for the resolver's lookup. var createRedirects = []*gormigrate.Migration{ { ID: "202610270006_create_golem15_seo_redirects", Migrate: func(tx *gorm.DB) error { - return tx.Exec(`CREATE TABLE golem15_seo_redirects ( + if err := tx.Exec(`CREATE TABLE golem15_seo_redirects ( id BIGSERIAL PRIMARY KEY, source TEXT NOT NULL, match TEXT NOT NULL DEFAULT 'exact', @@ -26,7 +26,11 @@ var createRedirects = []*gormigrate.Migration{ created_at TIMESTAMP(0) NULL, updated_at TIMESTAMP(0) NULL, CONSTRAINT golem15_seo_redirects_source_match UNIQUE (source, match) -)`).Error +)`).Error; err != nil { + return err + } + // The resolver reads exact rules by their lowercased source. + return tx.Exec(`CREATE INDEX golem15_seo_redirects_lower_source ON golem15_seo_redirects (lower(source))`).Error }, Rollback: func(tx *gorm.DB) error { return tx.Exec(`DROP TABLE IF EXISTS golem15_seo_redirects`).Error