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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user