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:
2026-10-27 11:26:42 +01:00
parent e31ac7c1c6
commit 34a65b846e
8 changed files with 1221 additions and 21 deletions

View File

@@ -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{})
}