223 lines
6.4 KiB
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
|
|
}
|