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 }