Files
sm-seo-plugin/routes.go
Jakub Zych aa0c3bcacf feat: management API and an MCP tool per SEO admin operation
- /_seo/api/v1 routes for the effective SEO of a URL with sources and
  owner, URL rules, redirects (loops refused with redirect_loop), the
  404 log actions, robots.txt and its tester, settings and history
- the CMS-data issue report: missing and duplicate titles and
  descriptions, rules over records, redirect chains, canonicals to
  noindex or redirected addresses, noindex addresses robots.txt blocks
- 24 MCP tools on those routes (backend guard, golem15.seo.manage)
- AdminOperations maps every admin operation to its tool;
  TestEveryAdminOperationHasTool and TestMCPReadToolsAreGET guard drift
- robots.txt writes go through classes.SaveRobots from the admin too
2026-10-27 12:17:36 +01:00

108 lines
4.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)
// 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("/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.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).
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),
}
}