Files
sm-seo-plugin/models/redirect.go
Jakub Zych 7677d8d8af fix: the Open/Resolved and Chains filters apply an exact rule's slash, case and query options (WR-22)
coveredSQL and chainSQL compared the source literally, so /missing/ or
/missing?utm=x stayed open after /missing answered it, and /a -> /b/ was
no chain next to /b. Both now use one SQL form of the runtime's exact
match (prefiltered on the indexed source_key). Pattern and regex rules
remain with the per-row badge, which the list_not_found schema text now
says.
2026-10-27 15:41:18 +01:00

301 lines
12 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}
}
// coversSQL is an SQL condition, true when the exact redirect aliased
// r matches the address addr (an SQL expression of a path with an optional
// query) the way the runtime matcher does (classes.exactMatches): the
// source's path with its ignore_case and trailing_slash options, and, when
// the source has a query, the address's query exactly (WR-22). The
// source_key test (the generated column of classes.exactKey) is a
// necessary condition that lets Postgres use its index; the rest is exact.
// Pattern and regex rules are not covered: their expressions are RE2 and
// only the compiled RedirectSet evaluates them.
func coversSQL(r, addr string) string {
return strings.NewReplacer("R.", r+".", "ADDR", addr).Replace(exactCoversSQL)
}
// exactCoversSQL is coversSQL with the placeholders R. and ADDR.
const exactCoversSQL = `(R.match = 'exact'
AND R.source_key IN (
lower(CASE WHEN length(split_part(ADDR, '?', 1)) > 1 AND right(split_part(ADDR, '?', 1), 1) = '/' THEN left(split_part(ADDR, '?', 1), -1) ELSE split_part(ADDR, '?', 1) END),
lower(CASE WHEN length(split_part(ADDR, '?', 1)) > 1 AND right(split_part(ADDR, '?', 1), 1) = '/' THEN left(split_part(ADDR, '?', 1), -1) ELSE split_part(ADDR, '?', 1) END)
|| '?' || (CASE WHEN strpos(ADDR, '?') > 0 THEN substr(ADDR, strpos(ADDR, '?') + 1) ELSE '' END))
AND (strpos(R.source, '?') = 0
OR substr(R.source, strpos(R.source, '?') + 1) = (CASE WHEN strpos(ADDR, '?') > 0 THEN substr(ADDR, strpos(ADDR, '?') + 1) ELSE '' END))
AND (split_part(R.source, '?', 1) = split_part(ADDR, '?', 1)
OR (R.ignore_case AND lower(split_part(R.source, '?', 1)) = lower(split_part(ADDR, '?', 1)))
OR (R.trailing_slash AND (
(CASE WHEN length(split_part(R.source, '?', 1)) > 1 AND right(split_part(R.source, '?', 1), 1) = '/' THEN left(split_part(R.source, '?', 1), -1) ELSE split_part(R.source, '?', 1) END)
= (CASE WHEN length(split_part(ADDR, '?', 1)) > 1 AND right(split_part(ADDR, '?', 1), 1) = '/' THEN left(split_part(ADDR, '?', 1), -1) ELSE split_part(ADDR, '?', 1) END)
OR (R.ignore_case AND lower(CASE WHEN length(split_part(R.source, '?', 1)) > 1 AND right(split_part(R.source, '?', 1), 1) = '/' THEN left(split_part(R.source, '?', 1), -1) ELSE split_part(R.source, '?', 1) END)
= lower(CASE WHEN length(split_part(ADDR, '?', 1)) > 1 AND right(split_part(ADDR, '?', 1), 1) = '/' THEN left(split_part(ADDR, '?', 1), -1) ELSE split_part(ADDR, '?', 1) END))))))`
// chainSQL is true for a redirect whose target leads into another
// redirect: a path target that another rule's source names literally or
// an exact rule matches with its options (WR-22). Absolute targets on the
// site URL and pattern or regex next hops are only seen by the save-time
// check (the chain notice).
var chainSQL = `EXISTS (SELECT 1 FROM golem15_seo_redirects AS next WHERE next.id <> golem15_seo_redirects.id AND (next.source = golem15_seo_redirects.target OR (golem15_seo_redirects.target LIKE '/%' AND golem15_seo_redirects.target NOT LIKE '//%' AND ` +
coversSQL("next", "golem15_seo_redirects.target") + `)))`
// 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
}