Files
sm-seo-plugin/classes/resolver.go

223 lines
6.4 KiB
Go

package classes
import (
"context"
"errors"
"net/url"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
)
// 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))
}
// 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]
}
// Resolve answers the effective SEO of rawURL, a path or an absolute URL of
// the site. A path no published record owns answers not_found.
func (r *Resolver) Resolve(ctx context.Context, rawURL string) (Result, error) {
path, _, err := parseTarget(rawURL)
if err != nil {
return Result{}, err
}
site := r.Service.SiteURL()
res := Result{Action: ActionNotFound, Status: 404, URL: site + path, Sources: map[string]string{}}
key, match, rec, err := r.lookup(ctx, path)
if err != nil {
return Result{}, err
}
if key == "" {
return res, nil
}
typ, _ := r.Service.Registry().ContentType(key)
db, err := r.Service.DB(ctx)
if err != nil {
return Result{}, err
}
entry, _, err := LoadEntry(db, typ.Content.Model, match.ID, match.Locale)
if err != nil {
return Result{}, err
}
content := contentLocale(rec, match.Locale)
seo := &SEO{Canonical: site + path, Verification: []MetaTag{}}
if entry.Title != "" {
seo.Title, res.Sources["title"] = entry.Title, SourceRecord
} else {
seo.Title, res.Sources["title"] = content.Title, SourceContent
}
res.Action, res.Status, res.Locale, res.SEO = ActionOK, 200, match.Locale, seo
res.TypeKey, res.RecordID = key, match.ID
return res, nil
}