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 }