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
This commit is contained in:
337
classes/redirect_graph.go
Normal file
337
classes/redirect_graph.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user