Files
sm-seo-plugin/classes/registry.go
Jakub Zych 9de6b2ec83 feat: noindex paths from the route report leave the sitemaps (tracer)
The route report takes an optional noindex list of static paths. When
the key is present the stored set (golem15_seo_noindex_paths) is
replaced, and the sitemap filter leaves those paths out with the reason
noindex; a changed set asks the sitemap for a debounced rebuild through
contract.Rebuilder. A listed noindex address without a CMS record now
names sitemap_update_settings, or fix: code without the sitemap plugin.
2026-11-04 22:44:26 +01:00

980 lines
30 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/sm-sitemap-plugin/contract"
"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,
// A registry page's fields are changed through seo_update_page,
// the tool the site audit names for its content (P33-38).
Tools: map[string]string{pact.ContentToolEdit: "seo_update_page"},
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
}
// ReportNoindex replaces the stored noindex paths (P33-58, the route
// report's noindex key) inside tx with the valid entries of paths, each
// normalised like a reported route (normalPagePath with the site's address
// options; a percent-encoded path is stored decoded when its decoded form
// is a valid address, the form the sitemap filter compares). An entry that
// is no valid address, or that is a pattern (a "*" or a ":" parameter
// segment), is skipped and counted. changed reports whether the stored set
// differs from the one before; when it does, the sitemap is asked for one
// debounced rebuild through contract.Rebuilder once tx commits (nothing
// when no Rebuilder is published). More than MaxReportedRoutes paths is
// ErrTooManyRoutes and stores nothing.
func (s *Service) ReportNoindex(ctx context.Context, tx *gorm.DB, paths []string) (stored int, skipped int, changed bool, err error) {
if len(paths) > MaxReportedRoutes {
return 0, 0, false, ErrTooManyRoutes
}
settings, err := SettingsRow(tx)
if err != nil {
return 0, 0, false, err
}
opts := NormalizeOptionsOf(settings)
want := map[string]bool{}
for _, raw := range paths {
path, ok := noindexPath(raw, opts)
if !ok {
skipped++
continue
}
want[path] = true
}
var have []string
if err := tx.Model(&models.NoindexPath{}).Pluck("path", &have).Error; err != nil {
return 0, skipped, false, err
}
changed = len(have) != len(want)
for _, p := range have {
if !want[p] {
changed = true
break
}
}
if !changed {
return len(want), skipped, false, nil
}
if err := tx.Exec(`DELETE FROM golem15_seo_noindex_paths`).Error; err != nil {
return 0, skipped, false, err
}
now := time.Now().UTC()
rows := make([]models.NoindexPath, 0, len(want))
for p := range want {
rows = append(rows, models.NoindexPath{Path: p, ReportedAt: now})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].Path < rows[j].Path })
if len(rows) > 0 {
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(rows, 500).Error; err != nil {
return 0, skipped, false, err
}
}
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
s.requestSitemapRebuild(ctx)
})
return len(rows), skipped, true, nil
}
// NoindexPathCount is the number of stored noindex paths.
func NoindexPathCount(db *gorm.DB) (int, error) {
var n int64
err := db.Model(&models.NoindexPath{}).Count(&n).Error
return int(n), err
}
// noindexPath is a reported noindex entry as it is stored: trimmed,
// decoded when its decoded form is a valid address, and normalised; ok is
// false for an invalid address or a pattern.
func noindexPath(raw string, opts NormalizeOptions) (string, bool) {
path := strings.TrimSpace(raw)
if len(path) > MaxRouteBytes || !ValidPagePath(path) || strings.Contains(path, "*") {
return "", false
}
for _, seg := range strings.Split(path, "/") {
if strings.HasPrefix(seg, ":") {
return "", false
}
}
if strings.Contains(path, "%") {
if dec, err := url.PathUnescape(path); err == nil && ValidPagePath(dec) {
path = dec
}
}
return normalPagePath(path, opts), true
}
// requestSitemapRebuild asks the sitemap plugin for one debounced rebuild
// through contract.Rebuilder; nothing when none is published, a warning
// when the request fails.
func (s *Service) requestSitemapRebuild(ctx context.Context) {
if s == nil || s.app == nil {
return
}
rb, ok := s.app.Lookup[contract.Rebuilder]()
if !ok || rb == nil {
return
}
if err := rb.RequestRebuild(ctx); err != nil {
log.Warn("golem15.seo: request a sitemap rebuild", "error", err)
}
}
// 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
}