589 lines
18 KiB
Go
589 lines
18 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 or not_found; 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"`
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|