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:
@@ -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{})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user