A label that is not a phrase key (no ::) is used as written, so the translator no longer logs it as a missing key.
869 lines
26 KiB
Go
869 lines
26 KiB
Go
package classes
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"git.golem15.com/golem15/sm-seo-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// The page registry (D-33): the site's own frontend pages (home, contact,
|
|
// offer pages) as a signpost content type of the SEO plugin, so each page
|
|
// gets the SEO tab, resolves ok and is listed in the sitemaps.
|
|
const (
|
|
// PageTypeKey is the registry's menu item type.
|
|
PageTypeKey = "seo-page"
|
|
// PagesController is the Pages screen's admin controller.
|
|
PagesController = "golem15.seo.pages"
|
|
// PagePattern is the registry type's pattern. A registry page stores
|
|
// its own address per locale, so the pattern only satisfies signpost.
|
|
PagePattern = "/:page"
|
|
// PageLabel is the registry type's label (a content type's label names
|
|
// its tab in the SEO settings, so it is not the plain "Page" another
|
|
// content type may use).
|
|
PageLabel = "golem15.seo::lang.pages.type"
|
|
)
|
|
|
|
// PageModel is the registry page's morph type, the model its SEO entries
|
|
// and record events name.
|
|
var PageModel = models.Page{}.MorphName()
|
|
|
|
// The route report's limits (P33-20).
|
|
const (
|
|
MaxReportedRoutes = 2000
|
|
MaxRouteBytes = 2048
|
|
)
|
|
|
|
// RouteReportTokenConfigKey is the bearer token the frontend server sends
|
|
// with its route report to POST /_seo/api/v1/registry/routes (P33-20).
|
|
// Empty uses golem15.seo.notfound_token; both empty turn the report off.
|
|
const RouteReportTokenConfigKey = "golem15.seo.route_report_token"
|
|
|
|
// RouteReportToken is the route report's token: route_report_token, else
|
|
// notfound_token, "" when neither is set.
|
|
func (s *Service) RouteReportToken() string {
|
|
if cfg := s.Config(); cfg != nil {
|
|
if t := strings.TrimSpace(cfg.String(RouteReportTokenConfigKey)); t != "" {
|
|
return t
|
|
}
|
|
}
|
|
return s.NotFoundToken()
|
|
}
|
|
|
|
// PageOrigin is the origin of a page created through the management API:
|
|
// mcp through MCP, else manual.
|
|
func PageOrigin(ctx context.Context) string {
|
|
if ViaOf(ctx) == models.ViaMCP {
|
|
return models.PageOriginMCP
|
|
}
|
|
return models.PageOriginManual
|
|
}
|
|
|
|
// ErrTooManyRoutes refuses a route report of more than MaxReportedRoutes
|
|
// paths.
|
|
var ErrTooManyRoutes = errors.New("golem15.seo: a route report holds at most 2000 paths")
|
|
|
|
// The phrase keys of the registry refusals (UI-SPEC C21).
|
|
const (
|
|
errPageURLRequired = "golem15.seo::lang.pages.url_required"
|
|
errPageURLInvalid = "golem15.seo::lang.pages.url_invalid"
|
|
errPageURLTaken = "golem15.seo::lang.pages.url_taken"
|
|
errPageURLOwned = "golem15.seo::lang.pages.url_owned"
|
|
errPageParentLoop = "golem15.seo::lang.pages.parent_loop"
|
|
errValueInvalid = "golem15.seo::lang.api.value_invalid"
|
|
)
|
|
|
|
// pageGroups are the sitemap file groups of the page kinds (P33-18).
|
|
var pageGroups = map[string]string{
|
|
models.PageKindPage: "pages",
|
|
models.PageKindOffer: "offers",
|
|
models.PageKindService: "services",
|
|
models.PageKindLanding: "landing-pages",
|
|
}
|
|
|
|
// PageKindLabels are the phrase keys of the page kinds.
|
|
var PageKindLabels = map[string]string{
|
|
models.PageKindPage: "golem15.seo::lang.pages.kind_page",
|
|
models.PageKindOffer: "golem15.seo::lang.pages.kind_offer",
|
|
models.PageKindService: "golem15.seo::lang.pages.kind_service",
|
|
models.PageKindLanding: "golem15.seo::lang.pages.kind_landing",
|
|
}
|
|
|
|
// PageGroup is the sitemap file group of a page kind; an unknown kind is
|
|
// listed with the pages.
|
|
func PageGroup(kind string) string {
|
|
if g, ok := pageGroups[kind]; ok {
|
|
return g
|
|
}
|
|
return pageGroups[models.PageKindPage]
|
|
}
|
|
|
|
// LoadPage returns registry page id; ErrNoRecord when there is none.
|
|
func LoadPage(db *gorm.DB, id uint) (models.Page, error) {
|
|
var rows []models.Page
|
|
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
|
|
return models.Page{}, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return models.Page{}, ErrNoRecord
|
|
}
|
|
return rows[0], nil
|
|
}
|
|
|
|
// pageByID reads a registry page by its id as text, nil when none.
|
|
func pageByID(db *gorm.DB, id string) (*models.Page, error) {
|
|
n, err := strconv.ParseUint(strings.TrimSpace(id), 10, 64)
|
|
if err != nil || n == 0 {
|
|
return nil, nil
|
|
}
|
|
p, err := LoadPage(db, uint(n))
|
|
if errors.Is(err, ErrNoRecord) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
// PageAt returns the registry page whose address in any locale is path,
|
|
// with that locale; nil when none.
|
|
func (s *Service) PageAt(ctx context.Context, db *gorm.DB, path string) (*models.Page, string, error) {
|
|
var rows []models.Page
|
|
if err := db.Where("url = ?", path).Limit(1).Find(&rows).Error; err != nil {
|
|
return nil, "", err
|
|
}
|
|
if len(rows) > 0 {
|
|
return &rows[0], s.DefaultLocale(ctx, db), nil
|
|
}
|
|
var hit []struct {
|
|
ID uint `gorm:"column:id"`
|
|
Locale string `gorm:"column:locale"`
|
|
}
|
|
if err := db.Raw(`SELECT p.id, e.key AS locale FROM golem15_seo_pages p, jsonb_each_text(p.urls) e WHERE e.value = ? ORDER BY p.id LIMIT 1`, path).Scan(&hit).Error; err != nil {
|
|
return nil, "", err
|
|
}
|
|
if len(hit) == 0 {
|
|
return nil, "", nil
|
|
}
|
|
p, err := LoadPage(db, hit[0].ID)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if def := s.DefaultLocale(ctx, db); hit[0].Locale == def {
|
|
return nil, "", nil
|
|
}
|
|
return &p, hit[0].Locale, nil
|
|
}
|
|
|
|
// PageRecord describes a registry page as a signpost content record: its
|
|
// address and name per locale (the default locale first, then the other
|
|
// enabled locales with an address), its content date as lastmod and its
|
|
// parent page.
|
|
func (s *Service) PageRecord(ctx context.Context, db *gorm.DB, p models.Page) pact.ContentRecord {
|
|
def := s.DefaultLocale(ctx, db)
|
|
urls := p.LocaleURLs(def)
|
|
rec := pact.ContentRecord{ID: p.IDText(), Published: true, LastMod: p.UpdatedAt}
|
|
if p.ContentUpdatedAt != nil && !p.ContentUpdatedAt.IsZero() {
|
|
day := p.ContentUpdatedAt.Time(time.UTC)
|
|
rec.LastMod = &day
|
|
}
|
|
for _, locale := range s.pageLocales(ctx, db, urls) {
|
|
rec.Locales = append(rec.Locales, pact.ContentLocale{Locale: locale, URL: urls[locale], Title: p.TitleIn(locale)})
|
|
}
|
|
if p.ParentID != nil && *p.ParentID != 0 {
|
|
rec.Parents = []pact.ContentRef{{Type: PageTypeKey, ID: strconv.FormatUint(uint64(*p.ParentID), 10)}}
|
|
}
|
|
return rec
|
|
}
|
|
|
|
// pageLocales orders the locales of urls: the site's locales first (the
|
|
// default one leading), then any other stored locale by code.
|
|
func (s *Service) pageLocales(ctx context.Context, db *gorm.DB, urls map[string]string) []string {
|
|
var out []string
|
|
seen := map[string]bool{}
|
|
for _, l := range s.Locales(ctx, db) {
|
|
if urls[l] != "" && !seen[l] {
|
|
out = append(out, l)
|
|
seen[l] = true
|
|
}
|
|
}
|
|
var rest []string
|
|
for l := range urls {
|
|
if !seen[l] {
|
|
rest = append(rest, l)
|
|
}
|
|
}
|
|
sort.Strings(rest)
|
|
return append(out, rest...)
|
|
}
|
|
|
|
// PageMenuItemType is the registry's menu item type seo-page: a menu item
|
|
// may point at a registry page, and its content descriptor lists, describes
|
|
// and matches the pages for signpost's consumers (the SEO tab, the
|
|
// resolver, the sitemaps). service is asked on every call.
|
|
func PageMenuItemType(service func() *Service) pact.MenuItemType {
|
|
svc := func() (*Service, error) {
|
|
if service == nil {
|
|
return nil, ErrNotBooted
|
|
}
|
|
s := service()
|
|
if s == nil {
|
|
return nil, ErrNotBooted
|
|
}
|
|
return s, nil
|
|
}
|
|
return pact.MenuItemType{
|
|
Key: PageTypeKey,
|
|
Label: PageLabel,
|
|
DefaultPattern: PagePattern,
|
|
References: func(ctx context.Context) ([]pact.MenuItemReference, error) {
|
|
s, err := svc()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.pageReferences(ctx)
|
|
},
|
|
Resolve: func(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
|
s, err := svc()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.resolvePageItem(ctx, in.Reference)
|
|
},
|
|
Content: &pact.ContentDescriptor{
|
|
Controller: PagesController,
|
|
Model: PageModel,
|
|
Kind: pact.ContentKindPage,
|
|
Records: func(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
|
|
s, err := svc()
|
|
if err != nil {
|
|
return pact.ContentPage{}, err
|
|
}
|
|
return s.pageRecords(ctx, in)
|
|
},
|
|
Describe: func(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
|
s, err := svc()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db, err := s.DB(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p, err := pageByID(db, in.ID)
|
|
if err != nil || p == nil {
|
|
return nil, err
|
|
}
|
|
rec := s.PageRecord(ctx, db, *p)
|
|
return &rec, nil
|
|
},
|
|
Match: func(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
|
s, err := svc()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db, err := s.DB(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p, locale, err := s.PageAt(ctx, db, in.Path)
|
|
if err != nil || p == nil {
|
|
return nil, err
|
|
}
|
|
return &pact.ContentMatchResult{ID: p.IDText(), Locale: locale, Exact: true}, nil
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// pageRecords is one page of registry records by id.
|
|
func (s *Service) pageRecords(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
|
|
db, err := s.DB(ctx)
|
|
if err != nil {
|
|
return pact.ContentPage{}, err
|
|
}
|
|
limit := in.Limit
|
|
if limit <= 0 || limit > 1000 {
|
|
limit = 1000
|
|
}
|
|
after, _ := strconv.ParseUint(strings.TrimSpace(in.After), 10, 64)
|
|
var rows []models.Page
|
|
if err := db.Where("id > ? AND url <> ''", after).Order("id ASC").Limit(limit).Find(&rows).Error; err != nil {
|
|
return pact.ContentPage{}, err
|
|
}
|
|
out := pact.ContentPage{}
|
|
for _, p := range rows {
|
|
out.Records = append(out.Records, s.PageRecord(ctx, db, p))
|
|
}
|
|
if len(rows) == limit {
|
|
out.Next = rows[len(rows)-1].IDText()
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// pageReferences lists the registry pages a menu item can point at, as a
|
|
// tree by parent, each level by address.
|
|
func (s *Service) pageReferences(ctx context.Context) ([]pact.MenuItemReference, error) {
|
|
db, err := s.DB(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var rows []models.Page
|
|
if err := db.Where("url <> ''").Order("url ASC").Limit(5000).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
children := map[uint][]models.Page{}
|
|
known := map[uint]bool{}
|
|
for _, p := range rows {
|
|
known[p.ID] = true
|
|
}
|
|
for _, p := range rows {
|
|
parent := p.Parent()
|
|
if !known[parent] {
|
|
parent = 0
|
|
}
|
|
children[parent] = append(children[parent], p)
|
|
}
|
|
visited := map[uint]bool{}
|
|
var build func(parent uint, depth int) []pact.MenuItemReference
|
|
build = func(parent uint, depth int) []pact.MenuItemReference {
|
|
var out []pact.MenuItemReference
|
|
for _, p := range children[parent] {
|
|
if visited[p.ID] || depth > 50 {
|
|
continue
|
|
}
|
|
visited[p.ID] = true
|
|
label := p.Title
|
|
if strings.TrimSpace(label) == "" {
|
|
label = p.URL
|
|
}
|
|
out = append(out, pact.MenuItemReference{Value: p.IDText(), Label: label, Items: build(p.ID, depth+1)})
|
|
}
|
|
return out
|
|
}
|
|
return build(0, 0), nil
|
|
}
|
|
|
|
// resolvePageItem resolves a menu item pointing at registry page id: its
|
|
// default-locale address and name, and one alternate per locale.
|
|
func (s *Service) resolvePageItem(ctx context.Context, id string) (*pact.ResolvedMenuItem, error) {
|
|
db, err := s.DB(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p, err := pageByID(db, id)
|
|
if err != nil || p == nil {
|
|
return nil, err
|
|
}
|
|
rec := s.PageRecord(ctx, db, *p)
|
|
item := &pact.ResolvedMenuItem{Title: p.Title, URL: p.URL, MTime: rec.LastMod}
|
|
for _, l := range rec.Locales {
|
|
item.Alternates = append(item.Alternates, pact.MenuItemAlternate{Locale: l.Locale, URL: l.URL, Title: l.Title})
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
// PageWrite is one change of a registry page through the API or MCP: a
|
|
// nil field is left as it is. URL and Title map locales to text; a locale
|
|
// sent empty removes that locale's address or name, and the "" key stands
|
|
// for the default locale. ParentSet with a nil ParentID moves the page to
|
|
// the top level.
|
|
type PageWrite struct {
|
|
URL map[string]string
|
|
Title map[string]string
|
|
Kind *string
|
|
ParentSet bool
|
|
ParentID *uint
|
|
ContentUpdatedAt *string
|
|
Note *string
|
|
}
|
|
|
|
// ApplyPageTexts merges submitted addresses and names into p: def is the
|
|
// default locale, whose values go to the url and title columns. Every
|
|
// address is trimmed and normalised with the site's address rules, so it
|
|
// is stored in the form the resolver matches.
|
|
func (s *Service) ApplyPageTexts(ctx context.Context, db *gorm.DB, p *models.Page, urls, titles map[string]string) error {
|
|
def := s.DefaultLocale(ctx, db)
|
|
settings, err := LoadSettings(db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
opts := NormalizeOptionsOf(settings)
|
|
if urls != nil {
|
|
others := map[string]string{}
|
|
for l, v := range p.URLs.Get() {
|
|
others[l] = v
|
|
}
|
|
for locale, path := range urls {
|
|
if locale == "" {
|
|
locale = def
|
|
}
|
|
path = normalPagePath(path, opts)
|
|
if locale == def {
|
|
p.URL = path
|
|
continue
|
|
}
|
|
if path == "" {
|
|
delete(others, locale)
|
|
} else {
|
|
others[locale] = path
|
|
}
|
|
}
|
|
p.URLs = lagoon.Jsonable[map[string]string]{Data: others, Valid: true}
|
|
}
|
|
if titles != nil {
|
|
others := map[string]string{}
|
|
for l, v := range p.Titles.Get() {
|
|
others[l] = v
|
|
}
|
|
for locale, title := range titles {
|
|
title = strings.TrimSpace(title)
|
|
if locale == "" {
|
|
locale = def
|
|
}
|
|
if locale == def {
|
|
p.Title = title
|
|
continue
|
|
}
|
|
if title == "" {
|
|
delete(others, locale)
|
|
} else {
|
|
others[locale] = title
|
|
}
|
|
}
|
|
p.Titles = lagoon.Jsonable[map[string]string]{Data: others, Valid: true}
|
|
}
|
|
if !p.URLs.Valid {
|
|
p.URLs = lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true}
|
|
}
|
|
if !p.Titles.Valid {
|
|
p.Titles = lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// normalPagePath trims an address and, when it is a valid path, applies
|
|
// the site's address rules (lowercase, trailing slash) to it.
|
|
func normalPagePath(path string, opts NormalizeOptions) string {
|
|
path = strings.TrimSpace(path)
|
|
if !ValidPagePath(path) {
|
|
return path
|
|
}
|
|
norm, _ := Normalize(&url.URL{Path: path}, opts)
|
|
if norm.Path == "" {
|
|
return path
|
|
}
|
|
return norm.Path
|
|
}
|
|
|
|
// ValidPagePath reports whether path is a registry address: a path
|
|
// starting with a single "/", without a domain, query or fragment, of at
|
|
// most MaxRouteBytes bytes of valid UTF-8 without control characters.
|
|
func ValidPagePath(path string) bool {
|
|
if path == "" || len(path) > MaxRouteBytes || !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") {
|
|
return false
|
|
}
|
|
if !utf8.ValidString(path) || strings.ContainsAny(path, "?#\\ ") {
|
|
return false
|
|
}
|
|
for _, r := range path {
|
|
if unicode.IsControl(r) {
|
|
return false
|
|
}
|
|
}
|
|
u, err := url.Parse(path)
|
|
return err == nil && !u.IsAbs() && u.Host == ""
|
|
}
|
|
|
|
// ApplyPageFields sets the scalar fields of w on p; an invalid kind or
|
|
// date is a problem.
|
|
func ApplyPageFields(p *models.Page, w PageWrite) map[string][]models.Problem {
|
|
problems := map[string][]models.Problem{}
|
|
if w.Kind != nil {
|
|
p.Kind = strings.TrimSpace(*w.Kind)
|
|
}
|
|
if w.ParentSet {
|
|
p.ParentID = nil
|
|
if w.ParentID != nil && *w.ParentID != 0 {
|
|
ref := models.PageRef(*w.ParentID)
|
|
p.ParentID = &ref
|
|
}
|
|
}
|
|
if w.ContentUpdatedAt != nil {
|
|
v := strings.TrimSpace(*w.ContentUpdatedAt)
|
|
if v == "" {
|
|
p.ContentUpdatedAt = nil
|
|
} else if d, err := lagoon.ParseDate(v); err == nil {
|
|
p.ContentUpdatedAt = &d
|
|
} else {
|
|
problems["content_updated_at"] = append(problems["content_updated_at"], models.Problem{Key: errValueInvalid})
|
|
}
|
|
}
|
|
if w.Note != nil {
|
|
p.Note = strings.TrimSpace(*w.Note)
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// CheckPage lists the refusals of a registry page before a save (UI-SPEC
|
|
// C21), keyed by field (url, kind, parent_id): a missing default-locale
|
|
// address, an invalid address, an address another registry page uses, an
|
|
// address another content type owns (naming the type and the record), an
|
|
// unknown kind, and a parent that is unknown or would make a loop.
|
|
func (s *Service) CheckPage(ctx context.Context, db *gorm.DB, p models.Page) (map[string][]models.Problem, error) {
|
|
problems := map[string][]models.Problem{}
|
|
add := func(field string, pr models.Problem) {
|
|
for _, have := range problems[field] {
|
|
if have.Key == pr.Key {
|
|
return
|
|
}
|
|
}
|
|
problems[field] = append(problems[field], pr)
|
|
}
|
|
if !models.KnownPageKind(p.Kind) {
|
|
add("kind", models.Problem{Key: errValueInvalid})
|
|
}
|
|
def := s.DefaultLocale(ctx, db)
|
|
if strings.TrimSpace(p.URL) == "" {
|
|
add("url", models.Problem{Key: errPageURLRequired})
|
|
}
|
|
urls := p.LocaleURLs(def)
|
|
var paths []string
|
|
for _, locale := range s.pageLocales(ctx, db, urls) {
|
|
path := urls[locale]
|
|
if !ValidPagePath(path) {
|
|
add("url", models.Problem{Key: errPageURLInvalid})
|
|
continue
|
|
}
|
|
paths = append(paths, path)
|
|
}
|
|
if len(paths) > 0 {
|
|
var taken int64
|
|
q := db.Model(&models.Page{}).Where(`(url IN ? OR EXISTS (SELECT 1 FROM jsonb_each_text(golem15_seo_pages.urls) e WHERE e.value IN ?))`, paths, paths)
|
|
if p.ID != 0 {
|
|
q = q.Where("id <> ?", p.ID)
|
|
}
|
|
if err := q.Count(&taken).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if taken > 0 {
|
|
add("url", models.Problem{Key: errPageURLTaken})
|
|
}
|
|
for _, path := range paths {
|
|
owner, err := s.ownerOutsideRegistry(ctx, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if owner != nil {
|
|
add("url", *owner)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if parent := p.Parent(); parent != 0 {
|
|
loop, known, err := parentLoop(db, p.ID, parent)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch {
|
|
case !known:
|
|
add("parent_id", models.Problem{Key: errValueInvalid})
|
|
case loop:
|
|
add("parent_id", models.Problem{Key: errPageParentLoop})
|
|
}
|
|
}
|
|
return problems, nil
|
|
}
|
|
|
|
// parentLoop walks up from parent: loop is true when it reaches id (or
|
|
// goes round), known false when parent does not exist.
|
|
func parentLoop(db *gorm.DB, id, parent uint) (loop, known bool, err error) {
|
|
seen := map[uint]bool{}
|
|
cur := parent
|
|
for depth := 0; cur != 0; depth++ {
|
|
if (id != 0 && cur == id) || seen[cur] || depth > 100 {
|
|
return true, true, nil
|
|
}
|
|
seen[cur] = true
|
|
p, err := LoadPage(db, cur)
|
|
if errors.Is(err, ErrNoRecord) {
|
|
return false, depth > 0, nil
|
|
}
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
if p.Parent() == 0 {
|
|
return false, true, nil
|
|
}
|
|
cur = p.Parent()
|
|
}
|
|
return false, true, nil
|
|
}
|
|
|
|
// ownerOutsideRegistry returns the url_owned refusal when a content type
|
|
// other than the registry owns path exactly: the type's label and the
|
|
// record's title as params. nil when none does.
|
|
func (s *Service) ownerOutsideRegistry(ctx context.Context, path string) (*models.Problem, error) {
|
|
reg := s.Registry()
|
|
if reg == nil {
|
|
return nil, nil
|
|
}
|
|
unprefixed := s.UnprefixedLocale()
|
|
for _, typ := range reg.ContentTypes() {
|
|
if typ.Key == PageTypeKey || typ.Content == nil || typ.Content.Match == nil {
|
|
continue
|
|
}
|
|
got, err := typ.Content.Match(ctx, pact.ContentMatch{Path: trimSlash(path), Pattern: reg.Pattern(typ.Key, ""), UnprefixedLocale: unprefixed})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if got == nil || !got.Exact {
|
|
continue
|
|
}
|
|
title := path
|
|
if rec, err := reg.Describe(ctx, typ.Key, got.ID, unprefixed); err == nil && rec != nil {
|
|
if l := contentLocale(rec, got.Locale); strings.TrimSpace(l.Title) != "" {
|
|
title = l.Title
|
|
}
|
|
}
|
|
label := strings.TrimSpace(typ.Label)
|
|
switch {
|
|
case label == "":
|
|
label = typ.Key
|
|
case strings.Contains(label, "::"):
|
|
label = s.Phrase(label, nil)
|
|
}
|
|
return &models.Problem{Key: errPageURLOwned, Params: map[string]string{"type": label, "title": title}}, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// PageValues are a registry page's values for history.
|
|
func PageValues(p models.Page) map[string]any {
|
|
parent := any(nil)
|
|
if p.Parent() != 0 {
|
|
parent = p.Parent()
|
|
}
|
|
date := ""
|
|
if p.ContentUpdatedAt != nil {
|
|
date = p.ContentUpdatedAt.String()
|
|
}
|
|
return map[string]any{
|
|
"url": p.URL, "urls": plainValue(p.URLs.Get()), "title": p.Title, "titles": plainValue(p.Titles.Get()),
|
|
"kind": p.Kind, "origin": p.Origin, "parent_id": parent, "content_updated_at": date, "note": p.Note,
|
|
}
|
|
}
|
|
|
|
// pageWhat is a registry page's history description.
|
|
func (s *Service) pageWhat(p models.Page) string {
|
|
return s.Phrase("golem15.seo::lang.history.what_page", map[string]string{"url": p.URL})
|
|
}
|
|
|
|
// RecordPage records a registry page's change in history.
|
|
func (s *Service) RecordPage(ctx context.Context, tx *gorm.DB, before map[string]any, p models.Page, deleted bool) error {
|
|
c := Change{Kind: models.SubjectPage, SubjectID: p.IDText(), What: s.pageWhat(p), Before: before}
|
|
if !deleted {
|
|
c.After = PageValues(p)
|
|
}
|
|
return s.Record(ctx, tx, c)
|
|
}
|
|
|
|
// SavePage checks and writes a registry page inside tx, records it in
|
|
// history, and fires the record event after commit, so the SEO tab's
|
|
// address tracking and the sitemap follow as they do for an admin save.
|
|
func (s *Service) SavePage(ctx context.Context, tx *gorm.DB, p *models.Page, before map[string]any) error {
|
|
problems, err := s.CheckPage(ctx, tx, *p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(problems) > 0 {
|
|
return &ProblemsError{Problems: problems}
|
|
}
|
|
now := time.Now().UTC()
|
|
created := p.ID == 0
|
|
if created {
|
|
p.CreatedAt = &now
|
|
}
|
|
p.UpdatedAt = &now
|
|
if err := tx.Save(p).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := s.RecordPage(ctx, tx, before, *p, false); err != nil {
|
|
return err
|
|
}
|
|
cabana.EmitRecordSaved(ctx, s.app, tx, pact.RecordSaved{Controller: PagesController, Model: PageModel, ID: p.IDText(), Created: created})
|
|
return nil
|
|
}
|
|
|
|
// DeletePage deletes a registry page inside tx, records it in history and
|
|
// fires the record event after commit: the SEO plugin then deletes the
|
|
// page's SEO entries and the sitemap drops its addresses.
|
|
func (s *Service) DeletePage(ctx context.Context, tx *gorm.DB, p models.Page) error {
|
|
if err := tx.Delete(&models.Page{}, p.ID).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := s.RecordPage(ctx, tx, PageValues(p), p, true); err != nil {
|
|
return err
|
|
}
|
|
cabana.EmitRecordDeleted(ctx, s.app, tx, pact.RecordDeleted{Controller: PagesController, Model: PageModel, ID: p.IDText()})
|
|
return nil
|
|
}
|
|
|
|
// RouteReport is the outcome of a route report: the paths inserted as new
|
|
// registry pages, the known pages whose reported_at moved, and the paths
|
|
// skipped (invalid, or owned by another content type).
|
|
type RouteReport struct {
|
|
Inserted int `json:"inserted"`
|
|
Updated int `json:"updated"`
|
|
Skipped int `json:"skipped"`
|
|
ReportedAt time.Time `json:"reported_at"`
|
|
}
|
|
|
|
// ReportRoutes takes the frontend server's static routes (D-33, P33-20)
|
|
// inside tx: a path no registry page has is inserted with origin reported,
|
|
// kind page and a name from its last segment; a known page gets
|
|
// reported_at; a path another content type owns, or that is no valid
|
|
// address, is skipped. Nothing is ever deleted, and no value an editor set
|
|
// is overwritten. The settings' routes_reported_at becomes the report's
|
|
// time, so a reported page missing from it shows as missing. More than
|
|
// MaxReportedRoutes paths is ErrTooManyRoutes.
|
|
func (s *Service) ReportRoutes(ctx context.Context, tx *gorm.DB, paths []string) (RouteReport, error) {
|
|
now := time.Now().UTC()
|
|
out := RouteReport{ReportedAt: now}
|
|
if len(paths) > MaxReportedRoutes {
|
|
return out, ErrTooManyRoutes
|
|
}
|
|
settings, err := SettingsRow(tx)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
opts := NormalizeOptionsOf(settings)
|
|
ctx = WithVia(ctx, models.ViaAuto)
|
|
seen := map[string]bool{}
|
|
for _, raw := range paths {
|
|
path := strings.TrimSpace(raw)
|
|
if len(path) > MaxRouteBytes || !ValidPagePath(path) {
|
|
out.Skipped++
|
|
continue
|
|
}
|
|
path = normalPagePath(path, opts)
|
|
if seen[path] {
|
|
continue
|
|
}
|
|
seen[path] = true
|
|
page, _, err := s.PageAt(ctx, tx, path)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
if page != nil {
|
|
if err := tx.Model(&models.Page{}).Where("id = ?", page.ID).UpdateColumn("reported_at", now).Error; err != nil {
|
|
return out, err
|
|
}
|
|
out.Updated++
|
|
continue
|
|
}
|
|
owned, err := s.ownerOutsideRegistry(ctx, path)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
if owned != nil {
|
|
out.Skipped++
|
|
continue
|
|
}
|
|
p := models.Page{
|
|
URL: path,
|
|
URLs: lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true},
|
|
Title: s.routeTitle(path),
|
|
Titles: lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true},
|
|
Kind: models.PageKindPage,
|
|
Origin: models.PageOriginReported,
|
|
ReportedAt: &now,
|
|
CreatedAt: &now,
|
|
UpdatedAt: &now,
|
|
}
|
|
res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&p)
|
|
if res.Error != nil {
|
|
return out, res.Error
|
|
}
|
|
if res.RowsAffected == 0 || p.ID == 0 {
|
|
// A concurrent report inserted it first.
|
|
continue
|
|
}
|
|
out.Inserted++
|
|
if err := s.RecordPage(ctx, tx, nil, p, false); err != nil {
|
|
return out, err
|
|
}
|
|
cabana.EmitRecordSaved(ctx, s.app, tx, pact.RecordSaved{Controller: PagesController, Model: PageModel, ID: p.IDText(), Created: true})
|
|
}
|
|
if err := tx.Model(&models.Settings{}).Where("id = ?", 1).UpdateColumn("routes_reported_at", now).Error; err != nil {
|
|
return out, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// routeTitle is a reported page's name: its last path segment with
|
|
// hyphens and underscores as spaces and the first letter capitalised; the
|
|
// home page is named pages.home_title.
|
|
func (s *Service) routeTitle(path string) string {
|
|
seg := strings.Trim(path, "/")
|
|
if i := strings.LastIndex(seg, "/"); i >= 0 {
|
|
seg = seg[i+1:]
|
|
}
|
|
if decoded, err := url.PathUnescape(seg); err == nil {
|
|
seg = decoded
|
|
}
|
|
seg = strings.TrimSpace(strings.NewReplacer("-", " ", "_", " ").Replace(seg))
|
|
if seg == "" {
|
|
return s.Phrase("golem15.seo::lang.pages.home_title", nil)
|
|
}
|
|
r, size := utf8.DecodeRuneInString(seg)
|
|
return string(unicode.ToUpper(r)) + seg[size:]
|
|
}
|
|
|
|
// PageMissing reports whether p is a reported page that the newest route
|
|
// report (at reported) did not name.
|
|
func PageMissing(p models.Page, reported *time.Time) bool {
|
|
if p.Origin != models.PageOriginReported || reported == nil {
|
|
return false
|
|
}
|
|
return p.ReportedAt == nil || p.ReportedAt.Before(*reported)
|
|
}
|
|
|
|
// pageKinds reads the kind of each registry page id (as text).
|
|
func pageKinds(db *gorm.DB, ids []string) (map[string]string, error) {
|
|
var nums []uint64
|
|
for _, id := range ids {
|
|
if n, err := strconv.ParseUint(id, 10, 64); err == nil {
|
|
nums = append(nums, n)
|
|
}
|
|
}
|
|
out := map[string]string{}
|
|
if len(nums) == 0 {
|
|
return out, nil
|
|
}
|
|
var rows []struct {
|
|
ID uint64 `gorm:"column:id"`
|
|
Kind string `gorm:"column:kind"`
|
|
}
|
|
if err := db.Raw(`SELECT id, kind FROM golem15_seo_pages WHERE id IN ?`, nums).Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range rows {
|
|
out[strconv.FormatUint(r.ID, 10)] = r.Kind
|
|
}
|
|
return out, nil
|
|
}
|