feat: route report endpoint

- POST /_seo/api/v1/registry/routes (public, bucket seo-notfound): the
  frontend server reports its static routes with the bearer token
  golem15.seo.route_report_token, else notfound_token (constant-time;
  both empty answer 404); at most 2000 paths
- unknown paths become registry pages (origin reported, kind page, a name
  from the last segment), known ones get reported_at; paths another
  content type owns and invalid ones are skipped; nothing is deleted or
  overwritten, and the report's time marks missing pages
This commit is contained in:
Jakub Zych
2026-11-04 13:22:12 +01:00
parent 9e802ec850
commit 26193ae4eb
7 changed files with 357 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ import (
"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,
@@ -45,6 +46,22 @@ const (
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 {
@@ -54,6 +71,10 @@ func PageOrigin(ctx context.Context) string {
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"
@@ -698,6 +719,117 @@ func (s *Service) DeletePage(ctx context.Context, tx *gorm.DB, p models.Page) er
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 {