Files
sm-seo-plugin/routes.go
Jakub Zych 26193ae4eb 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
2026-11-04 13:22:12 +01:00

123 lines
5.6 KiB
Go

package seo
import (
"net/http"
"time"
"git.golem15.com/golem15/sm-seo-plugin/classes"
apicontrollers "git.golem15.com/golem15/sm-seo-plugin/controllers/api"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/surf"
)
// The throttle buckets of the public endpoints.
const (
// BucketResolve is the resolve endpoint's bucket.
BucketResolve = "seo-resolve"
// BucketNotFound is the 404 report endpoint's bucket.
BucketNotFound = "seo-notfound"
// BucketRobots is the /robots.txt bucket.
BucketRobots = "seo-robots"
// BucketAPI is the management API's bucket (P-24).
BucketAPI = "seo-api"
)
// Routes mounts GET /robots.txt at the site root, the public /_seo/api/v1
// endpoints and the management API. The public ones carry no backend
// middleware: crawlers and the frontend server call them anonymously (the
// 404 report with its own token). The management routes (bucket seo-api)
// resolve the administrator themselves from a backend token or the MCP
// dispatch and require golem15.seo.manage (D-10, D-15); each one is an MCP
// tool (mcp.go). Every /_seo/api/v1 answer carries X-Robots-Tag: noindex.
func (p *Plugin) Routes(r pact.Router) error {
r.Get("/robots.txt", apicontrollers.Robots(p.Service), "throttle:"+BucketRobots)
r.Group("/_seo/api/v1", nil, func(g pact.Router) {
g.Get("/resolve", noindex(apicontrollers.Resolve(p.Service)), "throttle:"+BucketResolve)
g.Post("/notfound", noindex(apicontrollers.NotFound(p.Service)), "throttle:"+BucketNotFound)
// The frontend server's route report (D-33), token-authenticated
// like the 404 report.
g.Post("/registry/routes", noindex(apicontrollers.RouteReport(p.Service)), "throttle:"+BucketNotFound)
// Verification files are served here only; the frontend maps them
// at the site root (D-103). A root route would capture every
// one-segment page of a site served at GET /.
g.Get("/verification/{name}", noindex(apicontrollers.Verification(p.Service)), "throttle:"+BucketResolve)
g.Where("name", classes.VerificationNamePattern)
})
r.Group("/_seo/api/v1", nil, func(g pact.Router) {
api := "throttle:" + BucketAPI
g.Get("/seo", apicontrollers.GetSEO(p.Service), api)
g.Put("/seo", apicontrollers.SetSEO(p.Service), api)
g.Get("/rules", apicontrollers.ListRules(p.Service), api)
g.Post("/rules", apicontrollers.CreateRule(p.Service), api)
g.Get("/rules/{id}", apicontrollers.ShowRule(p.Service), api)
g.Put("/rules/{id}", apicontrollers.UpdateRule(p.Service), api)
g.Delete("/rules/{id}", apicontrollers.DeleteRule(p.Service), api)
g.Get("/redirects", apicontrollers.ListRedirects(p.Service), api)
g.Post("/redirects", apicontrollers.CreateRedirect(p.Service), api)
g.Get("/redirects/{id}", apicontrollers.ShowRedirect(p.Service), api)
g.Put("/redirects/{id}", apicontrollers.UpdateRedirect(p.Service), api)
g.Delete("/redirects/{id}", apicontrollers.DeleteRedirect(p.Service), api)
g.Get("/pages", apicontrollers.ListPages(p.Service), api)
g.Post("/pages", apicontrollers.CreatePage(p.Service), api)
g.Put("/pages/{id}", apicontrollers.UpdatePage(p.Service), api)
g.Delete("/pages/{id}", apicontrollers.DeletePage(p.Service), api)
g.Get("/notfound", apicontrollers.ListNotFound(p.Service), api)
g.Post("/notfound/{id}/redirect", apicontrollers.RedirectNotFound(p.Service), api)
g.Post("/notfound/{id}/gone", apicontrollers.GoneNotFound(p.Service), api)
g.Delete("/notfound/{id}", apicontrollers.DeleteNotFound(p.Service), api)
g.Get("/robots", apicontrollers.ShowRobots(p.Service), api)
g.Put("/robots", apicontrollers.UpdateRobots(p.Service), api)
g.Get("/robots/test", apicontrollers.TestRobotsURL(p.Service), api)
g.Get("/settings", apicontrollers.ShowSettings(p.Service), api)
g.Put("/settings", apicontrollers.UpdateSettings(p.Service), api)
g.Get("/history", apicontrollers.ListHistory(p.Service), api)
g.Get("/history/{id}", apicontrollers.ShowHistory(p.Service), api)
g.Post("/history/{id}/undo", apicontrollers.UndoChange(p.Service), api)
g.Post("/history/batches/{batch}/undo", apicontrollers.UndoBatch(p.Service), api)
g.Get("/export", apicontrollers.ExportCSV(p.Service), api)
g.Post("/import", apicontrollers.ImportCSV(p.Service), api)
g.Post("/batch", apicontrollers.ApplyBatch(p.Service), api)
g.Get("/issues", apicontrollers.ListIssues(p.Service), api)
})
return nil
}
// noindex adds X-Robots-Tag: noindex to every answer of h.
func noindex(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Robots-Tag", "noindex")
h(w, r)
}
}
// Buckets registers the per-client-IP buckets behind the configured
// trusted proxies: seo-resolve 600 a minute (the frontend server resolves
// every page it renders), seo-notfound 600 a minute (one report per 404
// page), seo-robots 120 a minute and seo-api 300 a minute (an MCP agent
// working through an audit). The resolve calls and 404 reports come from
// the frontend server, so that server must be a trusted proxy forwarding
// X-Forwarded-For, or the first two buckets cap the whole site (README,
// "Behind the frontend server").
func (p *Plugin) Buckets() map[string]surf.Bucket {
trusted := surf.TrustedProxies(nil)
if p.app != nil {
trusted = surf.TrustedProxies(p.app.Config)
}
bucket := func(name string, max int) surf.Bucket {
return surf.Bucket{
Name: name,
Max: max,
Decay: time.Minute,
Key: func(r *http.Request) string {
return name + "|" + surf.ClientIP(r, trusted)
},
}
}
return map[string]surf.Bucket{
BucketResolve: bucket(BucketResolve, 600),
BucketNotFound: bucket(BucketNotFound, 600),
BucketRobots: bucket(BucketRobots, 120),
BucketAPI: bucket(BucketAPI, 300),
}
}