- golem15.seo.redirects list and form (UI-SPEC C8): chain rows marked, status, match, origin and chain filters, the navigation item lands here - a field-less extension on the controller refuses a loop with redirects.loop on target, answers the chain notice and records history - the status dropdown posts text, read through RedirectStatus
268 lines
9.0 KiB
Go
268 lines
9.0 KiB
Go
package models
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// The match kinds of a redirect source (D-17).
|
|
const (
|
|
RedirectExact = "exact"
|
|
RedirectPattern = "pattern"
|
|
RedirectRegex = "regex"
|
|
)
|
|
|
|
// RedirectStatus is a redirect's HTTP status. The admin form's dropdown
|
|
// posts it as text ("301"), which UnmarshalText reads.
|
|
type RedirectStatus int
|
|
|
|
// UnmarshalText reads a status written as text.
|
|
func (s *RedirectStatus) UnmarshalText(b []byte) error {
|
|
n, err := strconv.Atoi(strings.TrimSpace(string(b)))
|
|
if err != nil {
|
|
return fmt.Errorf("models: redirect status %q is not a number", b)
|
|
}
|
|
*s = RedirectStatus(n)
|
|
return nil
|
|
}
|
|
|
|
// The statuses of a redirect (D-16).
|
|
const (
|
|
Status301 RedirectStatus = 301
|
|
Status302 RedirectStatus = 302
|
|
Status410 RedirectStatus = 410
|
|
)
|
|
|
|
// The query string options of a redirect (D-17).
|
|
const (
|
|
QueryIgnore = "ignore"
|
|
QueryPass = "pass"
|
|
)
|
|
|
|
// The origins of a redirect: who created it.
|
|
const (
|
|
OriginManual = "manual"
|
|
OriginSlug = "slug"
|
|
OriginImport = "import"
|
|
OriginMCP = "mcp"
|
|
OriginNotFound = "notfound"
|
|
)
|
|
|
|
// Redirect is one redirect rule on golem15_seo_redirects (D-16, D-17):
|
|
// Source is a path (exact, optionally with a query), a pattern where *
|
|
// matches any text, or a regular expression; Target is a path or an
|
|
// absolute URL, empty for 410 Gone. IgnoreCase compares the source without
|
|
// letter case; TrailingSlash matches the source with and without a
|
|
// trailing slash; Query ignores the request's query string or passes it to
|
|
// the target. Origin records who created the rule.
|
|
type Redirect struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Source string `gorm:"column:source"`
|
|
Match string `gorm:"column:match"`
|
|
Target string `gorm:"column:target"`
|
|
Status RedirectStatus `gorm:"column:status"`
|
|
Query string `gorm:"column:query"`
|
|
IgnoreCase bool `gorm:"column:ignore_case"`
|
|
TrailingSlash bool `gorm:"column:trailing_slash"`
|
|
Origin string `gorm:"column:origin"`
|
|
Note string `gorm:"column:note"`
|
|
CreatedAt *time.Time `gorm:"column:created_at"`
|
|
UpdatedAt *time.Time `gorm:"column:updated_at"`
|
|
}
|
|
|
|
func (Redirect) TableName() string { return "golem15_seo_redirects" }
|
|
|
|
// MorphName names a redirect in history.
|
|
func (Redirect) MorphName() string { return `Golem15\SEO\Models\Redirect` }
|
|
|
|
// NewRedirect is a redirect with the form's defaults: exact, 301, query
|
|
// ignored, trailing slash matched, manual.
|
|
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{})
|
|
}
|
|
|
|
// RedirectOrigins are the origins in filter order.
|
|
var RedirectOrigins = []string{OriginManual, OriginSlug, OriginImport, OriginMCP, OriginNotFound}
|
|
|
|
// The redirects list's filter scopes and the chain filter's values.
|
|
const (
|
|
FilterRedirectStatus = "FilterRedirectStatus"
|
|
FilterRedirectMatch = "FilterRedirectMatch"
|
|
FilterRedirectOrigin = "FilterRedirectOrigin"
|
|
FilterRedirectChain = "FilterRedirectChain"
|
|
ChainOnly = "only"
|
|
ChainHide = "hide"
|
|
)
|
|
|
|
// FilterScopes lists the redirects list's filter scopes.
|
|
func (Redirect) FilterScopes() []string {
|
|
return []string{FilterRedirectStatus, FilterRedirectMatch, FilterRedirectOrigin, FilterRedirectChain}
|
|
}
|
|
|
|
// chainSQL is true for a redirect whose target is another redirect's
|
|
// source.
|
|
const chainSQL = `EXISTS (SELECT 1 FROM golem15_seo_redirects AS next WHERE next.source = golem15_seo_redirects.target AND next.id <> golem15_seo_redirects.id)`
|
|
|
|
// FilterScope narrows the redirects list; an unknown value matches
|
|
// nothing.
|
|
func (Redirect) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
|
|
if db == nil {
|
|
return db
|
|
}
|
|
s, _ := value.(string)
|
|
switch name {
|
|
case FilterRedirectStatus:
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil || (n != 301 && n != 302 && n != 410) {
|
|
return db.Where("1 = 0")
|
|
}
|
|
return db.Where("golem15_seo_redirects.status = ?", n)
|
|
case FilterRedirectMatch:
|
|
if s != RedirectExact && s != RedirectPattern && s != RedirectRegex {
|
|
return db.Where("1 = 0")
|
|
}
|
|
return db.Where("golem15_seo_redirects.match = ?", s)
|
|
case FilterRedirectOrigin:
|
|
for _, o := range RedirectOrigins {
|
|
if o == s {
|
|
return db.Where("golem15_seo_redirects.origin = ?", s)
|
|
}
|
|
}
|
|
return db.Where("1 = 0")
|
|
case FilterRedirectChain:
|
|
switch s {
|
|
case ChainOnly:
|
|
return db.Where(chainSQL)
|
|
case ChainHide:
|
|
return db.Where("NOT " + chainSQL)
|
|
}
|
|
return db.Where("1 = 0")
|
|
}
|
|
return db
|
|
}
|