Files
sm-seo-plugin/classes/resolver.go
Jakub Zych 34a65b846e 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
2026-10-27 11:26:42 +01:00

621 lines
19 KiB
Go

package classes
import (
"context"
"errors"
"net/url"
"sort"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// Config keys of the resolver.
const (
// SiteURLConfigKey is the absolute origin of the public site (for
// example https://example.com). Canonical and other absolute URLs are
// built from it, falling back to app.url; never from a request's Host.
SiteURLConfigKey = "golem15.seo.site_url"
// UnprefixedLocaleConfigKey names the locale whose public URLs carry no
// locale prefix; empty leaves the choice to each content type.
UnprefixedLocaleConfigKey = "golem15.seo.unprefixed_locale"
)
// MaxURLBytes caps the URL the resolver accepts.
const MaxURLBytes = 2048
// ErrBadURL is returned for an input that is not a path or an http(s) URL.
var ErrBadURL = errors.New("golem15.seo: url must be a path or an absolute http(s) URL")
// The actions of a resolve answer.
const (
ActionOK = "ok"
ActionNotFound = "not_found"
)
// The sources of a resolved value (Result.Sources).
const (
SourceRule = "rule"
SourceRecord = "record"
SourceTemplate = "template"
SourceGlobal = "global"
SourceContent = "content"
SourceDefault = "default"
)
// Result is the effective SEO of one URL. Action is ok, not_found,
// redirect or gone; Status is the HTTP status the frontend should answer
// with. URL is the absolute URL of the page (path and kept query on the
// site URL). Sources maps each value name (title, description, canonical,
// robots, og_title, og_description, og_image) to where it came from.
type Result struct {
Action string `json:"action"`
Status int `json:"status"`
// Location is the absolute address a redirect answer sends the visitor
// to; absent on every other action.
Location string `json:"location,omitempty"`
URL string `json:"url"`
Locale string `json:"locale"`
Staging bool `json:"staging"`
SEO *SEO `json:"seo"`
Sources map[string]string `json:"-"`
// TypeKey and RecordID name the record behind the URL, "" when none.
TypeKey string `json:"-"`
RecordID string `json:"-"`
}
// SEO is the head metadata of a resolved page.
type SEO struct {
Title string `json:"title"`
Description string `json:"description"`
Canonical string `json:"canonical"`
Robots string `json:"robots"`
XRobotsTag string `json:"x_robots_tag"`
OG OpenGraph `json:"og"`
Twitter TwitterTag `json:"twitter"`
Verification []MetaTag `json:"verification"`
}
// OpenGraph holds the og: values.
type OpenGraph struct {
Title string `json:"title"`
Description string `json:"description"`
Image string `json:"image"`
URL string `json:"url"`
}
// TwitterTag holds the twitter: values.
type TwitterTag struct {
Card string `json:"card"`
Site string `json:"site"`
}
// MetaTag is one verification meta tag.
type MetaTag struct {
Name string `json:"name"`
Content string `json:"content"`
}
// Resolver answers the effective SEO of a URL.
type Resolver struct {
Service *Service
}
// Resolver returns the service's resolver.
func (s *Service) Resolver() *Resolver { return &Resolver{Service: s} }
// SiteURL is the configured public origin without a trailing slash:
// golem15.seo.site_url, else app.url, else "".
func (s *Service) SiteURL() string {
cfg := s.Config()
if cfg == nil {
return ""
}
for _, key := range []string{SiteURLConfigKey, "app.url"} {
if u := strings.TrimRight(strings.TrimSpace(cfg.String(key)), "/"); u != "" {
return u
}
}
return ""
}
// UnprefixedLocale is the configured unprefixed locale, "" when unset.
func (s *Service) UnprefixedLocale() string {
cfg := s.Config()
if cfg == nil {
return ""
}
return strings.TrimSpace(cfg.String(UnprefixedLocaleConfigKey))
}
// Staging reports whether the site runs outside production. It depends on
// the environment only (D-22): no setting, admin field or MCP tool can turn
// the lock off.
func (s *Service) Staging() bool {
cfg := s.Config()
if cfg == nil {
return true
}
return cfg.Environment() != "production"
}
// DefaultLocale is the site's default content locale: the translation
// writer's default, else the app locale.
func (s *Service) DefaultLocale(ctx context.Context, db *gorm.DB) string {
if s != nil && s.app != nil {
if w, ok := s.app.Lookup[cabana.TranslationWriter](); ok && w != nil {
if code, err := w.DefaultLocale(ctx, db); err == nil && code != "" {
return code
}
}
}
if cfg := s.Config(); cfg != nil {
if code := strings.TrimSpace(cfg.String("app.locale")); code != "" {
return code
}
}
return "en"
}
// translatedSetting returns a translatable settings column in locale: the
// stored translation when there is one, else the row's own value.
func (s *Service) translatedSetting(ctx context.Context, db *gorm.DB, row *models.Settings, field, own, locale string) string {
if s == nil || s.app == nil || locale == "" {
return own
}
w, ok := s.app.Lookup[cabana.TranslationWriter]()
if !ok || w == nil {
return own
}
if def, err := w.DefaultLocale(ctx, db); err == nil && def == locale {
return own
}
text, found, err := w.TranslatedExact(ctx, db, row, field, locale)
if err != nil || !found || strings.TrimSpace(text) == "" {
return own
}
return text
}
// LoadSettings reads settings row 1; without one the defaults apply.
func LoadSettings(db *gorm.DB) (models.Settings, error) {
var rows []models.Settings
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
return models.Settings{}, err
}
if len(rows) == 0 {
return models.DefaultSettings(), nil
}
return rows[0], nil
}
// parseTarget turns the resolver input into a path and its query. A path
// must start with "/"; an absolute URL must be http or https. Fragments are
// dropped.
func parseTarget(raw string) (string, url.Values, error) {
raw = strings.TrimSpace(raw)
if raw == "" || len(raw) > MaxURLBytes {
return "", nil, ErrBadURL
}
u, err := url.Parse(raw)
if err != nil {
return "", nil, ErrBadURL
}
if u.IsAbs() {
if u.Scheme != "http" && u.Scheme != "https" {
return "", nil, ErrBadURL
}
} else if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
return "", nil, ErrBadURL
}
path := u.Path
if path == "" {
path = "/"
}
return path, u.Query(), nil
}
// lookup finds the published record behind path; key is "" when none.
func (r *Resolver) lookup(ctx context.Context, path string) (key string, match *pact.ContentMatchResult, rec *pact.ContentRecord, err error) {
reg := r.Service.Registry()
if reg == nil {
return "", nil, nil, nil
}
key, match, err = reg.MatchPath(ctx, path, r.Service.UnprefixedLocale())
if err != nil || match == nil {
return "", nil, nil, err
}
rec, err = reg.Describe(ctx, key, match.ID, r.Service.UnprefixedLocale())
if err != nil {
return "", nil, nil, err
}
if rec == nil || !rec.Published {
return "", nil, nil, nil
}
return key, match, rec, nil
}
// contentLocale returns the record's description in locale, else its first
// locale.
func contentLocale(rec *pact.ContentRecord, locale string) pact.ContentLocale {
if rec == nil || len(rec.Locales) == 0 {
return pact.ContentLocale{Locale: locale}
}
for _, l := range rec.Locales {
if l.Locale == locale {
return l
}
}
return rec.Locales[0]
}
// matchingRules returns the URL rules that apply to path and query, the
// most specific first: exact rules, then patterns by the length of their
// address. A rule address without "?" is compared with the path only; one
// with a query with the path and the raw query.
func matchingRules(db *gorm.DB, path, rawQuery string) ([]models.Rule, error) {
var rows []models.Rule
if err := db.Where("match = ? OR url = ? OR url = ?", models.MatchPattern, path, path+"?"+rawQuery).Find(&rows).Error; err != nil {
return nil, err
}
full := path
if rawQuery != "" {
full = path + "?" + rawQuery
}
var out []models.Rule
for _, r := range rows {
target := path
if strings.Contains(r.URL, "?") {
target = full
}
if r.Match == models.MatchPattern {
if MatchGlob(r.URL, target) {
out = append(out, r)
}
} else if r.URL == target {
out = append(out, r)
}
}
sort.SliceStable(out, func(i, j int) bool {
if (out[i].Match == models.MatchExact) != (out[j].Match == models.MatchExact) {
return out[i].Match == models.MatchExact
}
return len(out[i].URL) > len(out[j].URL)
})
return out, nil
}
// ruleValue returns the first non-empty value of column among rules.
func ruleValue(rules []models.Rule, column string) string {
for _, r := range rules {
if v := strings.TrimSpace(r.Value(column)); v != "" {
return v
}
}
return ""
}
// pick returns the first non-empty candidate and its source.
type candidate struct {
value string
source string
}
func pick(cands ...candidate) (string, string) {
for _, c := range cands {
if strings.TrimSpace(c.value) != "" {
return strings.TrimSpace(c.value), c.source
}
}
return "", SourceDefault
}
// robotsLayer is one layer of the robots precedence chain.
type robotsLayer struct {
value string
source string
}
// resolveRobots merges the layers per directive (UI-SPEC C5): each
// directive comes from the first layer that sets it, index,follow last.
// The source is the layer that decided indexing. A malformed stored value
// is skipped.
func resolveRobots(layers []robotsLayer) (Robots, string) {
var out Robots
source := SourceDefault
for _, layer := range layers {
r, err := ParseRobots(layer.value)
if err != nil || r.Empty() {
continue
}
if out.Index == "" && r.Index != "" {
source = layer.source
}
out = out.Inherit(r)
}
return out.Inherit(Robots{Index: "index", Follow: "follow"}), source
}
// Resolve answers the effective SEO of rawURL, a path or an absolute URL of
// the site, with the D-02 precedence per value (P-14): URL rule > record >
// per-type template or global settings > content-derived values. A path
// with neither a published record nor a URL rule answers not_found, except
// the home page. Outside production every answer is noindex,nofollow with
// staging true (D-22). Absolute URLs come from SiteURL, never from a
// request.
func (r *Resolver) Resolve(ctx context.Context, rawURL string) (Result, error) {
path, query, err := parseTarget(rawURL)
if err != nil {
return Result{}, err
}
svc := r.Service
db, err := svc.DB(ctx)
if err != nil {
return Result{}, err
}
settings, err := LoadSettings(db)
if err != nil {
return Result{}, err
}
set, err := LoadRedirectSet(db)
if err != nil {
return Result{}, err
}
chain, err := set.Follow(svc.SiteURL(), path, rawQueryOf(rawURL), MaxRedirectHops)
if err != nil {
return Result{}, err
}
if len(chain.Hops) > 0 {
return r.redirectAnswer(svc.SiteURL()+joinQuery(path, rawQueryOf(rawURL)), chain), nil
}
key, match, rec, err := r.lookup(ctx, path)
if err != nil {
return Result{}, err
}
return r.resolveAt(ctx, db, settings, path, query, rawQueryOf(rawURL), key, match, rec, false)
}
// ResolveRecord answers the effective SEO of a record in one locale at its
// own address, published or not (the SEO tab's effective-values partial).
// ok is false when the record has no address in that locale.
func (r *Resolver) ResolveRecord(ctx context.Context, key, id, locale string) (Result, bool, error) {
svc := r.Service
reg := svc.Registry()
if reg == nil {
return Result{}, false, nil
}
rec, err := reg.Describe(ctx, key, id, svc.UnprefixedLocale())
if err != nil || rec == nil {
return Result{}, false, err
}
var path string
for _, l := range rec.Locales {
if l.Locale == locale {
path = l.URL
}
}
if strings.TrimSpace(path) == "" {
return Result{}, false, nil
}
target, query, err := parseTarget(path)
if err != nil {
return Result{}, false, nil
}
db, err := svc.DB(ctx)
if err != nil {
return Result{}, false, err
}
settings, err := LoadSettings(db)
if err != nil {
return Result{}, false, err
}
res, err := r.resolveAt(ctx, db, settings, target, query, rawQueryOf(path), key, &pact.ContentMatchResult{ID: id, Locale: locale, Exact: true}, rec, true)
return res, err == nil, err
}
// resolveAt computes the answer for a path whose owner lookup is done:
// key, match and rec name the record behind it (key "" when none).
func (r *Resolver) resolveAt(ctx context.Context, db *gorm.DB, settings models.Settings, path string, query url.Values, rawQuery, key string, match *pact.ContentMatchResult, rec *pact.ContentRecord, force bool) (Result, error) {
svc := r.Service
site := svc.SiteURL()
kept, filtered := splitQuery(settings.Params(), path, query)
self := site + path
if q := encodeQuery(kept); q != "" {
self += "?" + q
}
res := Result{Action: ActionNotFound, Status: 404, URL: self, Staging: svc.Staging(), Sources: map[string]string{}}
rules, err := matchingRules(db, path, rawQuery)
if err != nil {
return Result{}, err
}
home := path == "/"
if key == "" && len(rules) == 0 && !home && !force {
return res, nil
}
locale := ""
var entry models.Entry
var content pact.ContentLocale
typeSetting := models.TypeSetting{Inject: true}
if key != "" {
typ, _ := svc.Registry().ContentType(key)
locale = match.Locale
if entry, _, err = LoadEntry(db, typ.Content.Model, match.ID, locale); err != nil {
return Result{}, err
}
content = contentLocale(rec, locale)
if typeSetting, err = LoadTypeSetting(db, key); err != nil {
return Result{}, err
}
res.TypeKey, res.RecordID = key, match.ID
}
if locale == "" {
locale = svc.DefaultLocale(ctx, db)
}
siteName := svc.translatedSetting(ctx, db, &settings, "site_name", settings.SiteName, locale)
defaultDescription := svc.translatedSetting(ctx, db, &settings, "default_description", settings.DefaultDescription, locale)
vars := TemplateVars{Title: content.Title, Description: content.Description, Site: siteName}
seo := &SEO{Verification: []MetaTag{}}
src := res.Sources
// Title: rule > record > type template, else the general template, on
// the content title > content title > site name. The home page without
// a record uses the home template.
var titleTemplate candidate
switch {
case key != "":
tpl := typeSetting.TitleTemplate
if strings.TrimSpace(tpl) == "" {
tpl = settings.TitleTemplate
}
titleTemplate = candidate{RenderTemplate(tpl, vars), SourceTemplate}
case home:
titleTemplate = candidate{RenderTemplate(settings.HomeTemplate, vars), SourceTemplate}
}
seo.Title, src["title"] = pick(
candidate{ruleValue(rules, "title"), SourceRule},
candidate{entry.Title, SourceRecord},
titleTemplate,
candidate{content.Title, SourceContent},
candidate{siteName, SourceGlobal},
)
// Description: rule > record > type template > content > settings.
var descTemplate candidate
if key != "" {
descTemplate = candidate{RenderTemplate(typeSetting.DescriptionTemplate, vars), SourceTemplate}
}
seo.Description, src["description"] = pick(
candidate{ruleValue(rules, "description"), SourceRule},
candidate{entry.Description, SourceRecord},
descTemplate,
candidate{content.Description, SourceContent},
candidate{defaultDescription, SourceGlobal},
)
// Canonical: rule > record > self.
canonical, canonicalSource := pick(
candidate{ruleValue(rules, "canonical"), SourceRule},
candidate{entry.Canonical, SourceRecord},
)
if canonical == "" {
canonical, canonicalSource = self, SourceDefault
} else {
canonical = AbsoluteURL(site, canonical)
}
seo.Canonical, src["canonical"] = canonical, canonicalSource
// Robots per directive: rule > record > never-indexed paths, search
// paths and filter URLs > type default > index,follow.
layers := []robotsLayer{
{ruleValue(rules, "robots"), SourceRule},
{entry.Robots, SourceRecord},
}
switch {
case MatchPathList(settings.NoindexPaths, path):
layers = append(layers, robotsLayer{"noindex,nofollow", SourceGlobal})
case MatchPathList(settings.SearchPaths, path) || filtered:
layers = append(layers, robotsLayer{"noindex,follow", SourceGlobal})
}
if key != "" {
layers = append(layers, robotsLayer{typeSetting.Robots, SourceTemplate})
}
robots, robotsSource := resolveRobots(layers)
if res.Staging {
robots, robotsSource = Robots{Index: "noindex", Follow: "nofollow"}, SourceDefault
}
seo.Robots, src["robots"] = robots.Header(), robotsSource
seo.XRobotsTag = robots.Header()
// Social values (D-04): overrides, else the effective title and
// description; the image falls back to the content's, then the
// settings' default social image.
seo.OG.Title, src["og_title"] = pick(
candidate{ruleValue(rules, "og_title"), SourceRule},
candidate{entry.OGTitle, SourceRecord},
candidate{seo.Title, src["title"]},
)
seo.OG.Description, src["og_description"] = pick(
candidate{ruleValue(rules, "og_description"), SourceRule},
candidate{entry.OGDescription, SourceRecord},
candidate{seo.Description, src["description"]},
)
image, imageSource := pick(
candidate{ruleValue(rules, "og_image"), SourceRule},
candidate{entry.OGImage, SourceRecord},
candidate{content.Image, SourceContent},
candidate{settings.DefaultOGImage, SourceGlobal},
)
if image != "" {
image = AbsoluteURL(site, image)
}
seo.OG.Image, src["og_image"] = image, imageSource
seo.OG.URL = seo.Canonical
seo.Twitter.Card = "summary"
if image != "" {
seo.Twitter.Card = "summary_large_image"
}
seo.Twitter.Site = strings.TrimSpace(settings.TwitterSite)
// Verification meta (D-05) on the home page only.
if home {
seo.Verification = verificationMeta(settings)
}
res.Action, res.Status, res.Locale, res.SEO = ActionOK, 200, locale, seo
return res, nil
}
// redirectAnswer is the answer of a matched redirect chain (P-21): gone
// for a chain that ends on a 410, not_found (logged once per URL) for a
// cycle, else redirect with the chain's status and its final target
// absolute on the site URL, so the visitor makes one hop.
func (r *Resolver) redirectAnswer(self string, ch Chain) Result {
res := Result{URL: self, Staging: r.Service.Staging(), Sources: map[string]string{}}
switch {
case ch.Loop:
logRedirectCycle(self, ch)
res.Action, res.Status = ActionNotFound, 404
case ch.Gone:
res.Action, res.Status = ActionGone, models.Status410
default:
res.Action, res.Status, res.Location = ActionRedirect, ch.Status, AbsoluteURL(r.Service.SiteURL(), ch.Final)
}
return res
}
// rawQueryOf returns the raw query of the resolver input.
func rawQueryOf(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
return u.RawQuery
}
// verificationMeta lists the verification meta tags of the settings.
func verificationMeta(s models.Settings) []MetaTag {
out := []MetaTag{}
for _, m := range []MetaTag{
{Name: "google-site-verification", Content: s.VerifyGoogle},
{Name: "msvalidate.01", Content: s.VerifyBing},
{Name: "yandex-verification", Content: s.VerifyYandex},
} {
if strings.TrimSpace(m.Content) != "" {
m.Content = strings.TrimSpace(m.Content)
out = append(out, m)
}
}
for _, m := range s.OtherMeta() {
if strings.TrimSpace(m.Name) != "" && strings.TrimSpace(m.Content) != "" {
out = append(out, MetaTag{Name: strings.TrimSpace(m.Name), Content: strings.TrimSpace(m.Content)})
}
}
return out
}