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(site, 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(site, 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(site, 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. A // final target outside the site ends the list; one on the site (a path or // the site's absolute URL) is already its last visited path. func displayPath(site string, 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(site, 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 }