Files
sm-seo-plugin/classes/redirect_graph.go
Jakub Zych e957c00866 refactor: run the redirect simulation over a rule list
CheckRedirect loads the stored rules and hands them to
checkRedirectRules, which needs no database, so the loop and chain rules
can be unit-tested. No behaviour change.
2026-10-27 13:14:06 +01:00

346 lines
10 KiB
Go

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) {
if r.Status == models.Status410 {
return RedirectCheck{}, nil
}
rules, err := LoadRedirects(db.WithContext(ctx))
if err != nil {
return RedirectCheck{}, err
}
return checkRedirectRules(s.SiteURL(), rules, r)
}
// checkRedirectRules is CheckRedirect over the stored rules given, for the
// site URL site.
func checkRedirectRules(site string, rules []models.Redirect, r models.Redirect) (RedirectCheck, error) {
var check RedirectCheck
if r.Status == models.Status410 {
return check, nil
}
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)
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
}