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
This commit is contained in:
2026-10-27 12:17:36 +01:00
parent cadf6ad805
commit aa0c3bcacf
22 changed files with 2723 additions and 46 deletions

View File

@@ -1,6 +1,7 @@
package api
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
@@ -8,7 +9,9 @@ import (
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/wire"
"gorm.io/gorm"
)
// maxReportBody caps the 404 report body: the URL and the referer fit
@@ -62,3 +65,151 @@ func NotFound(service func() *classes.Service) http.HandlerFunc {
w.WriteHeader(http.StatusNoContent)
}
}
// ListNotFoundInput is the query of GET /_seo/api/v1/notfound.
type ListNotFoundInput struct {
Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"`
PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"rows per page, at most 200 (50 by default)"`
Search string `json:"search,omitempty" query:"search" jsonschema:"text the address contains"`
Status string `json:"status,omitempty" query:"status" jsonschema:"open (no redirect answers the address yet) or resolved"`
}
// NotFoundIDInput names one 404 log entry.
type NotFoundIDInput struct {
ID uint `json:"id" path:"id" jsonschema:"the 404 log entry id"`
}
// RedirectNotFoundInput is POST /_seo/api/v1/notfound/{id}/redirect.
type RedirectNotFoundInput struct {
ID uint `json:"id" path:"id" jsonschema:"the 404 log entry id"`
Target string `json:"target" jsonschema:"where to send visitors: a path or an absolute URL"`
}
// NotFoundJSON is a 404 log entry in an answer, with the rule that
// answers it now (resolved) when there is one.
func NotFoundJSON(n models.NotFound, cover *models.Redirect) map[string]any {
out := map[string]any{
"id": n.ID, "url": n.URL, "hits": n.Hits, "first_seen": n.FirstSeen,
"last_hit": n.LastHit, "referer": n.Referer, "resolved": cover != nil,
}
if cover != nil {
out["redirect_id"] = cover.ID
}
return out
}
// ListNotFound serves GET /_seo/api/v1/notfound: the 404 log by hits,
// filtered by search text and open or resolved (an exact redirect or 410
// answers the address).
func ListNotFound(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
in := listParams(r)
q := db.Model(&models.NotFound{}).Order("hits DESC, id ASC")
if in.Search != "" {
q = q.Where("lower(url) LIKE ?", like(in.Search))
}
if s := r.URL.Query().Get("status"); s != "" {
q = (models.NotFound{}).FilterScope(models.FilterNotFoundStatus, q, s)
}
var rows []models.NotFound
total, err := paginate(q, in, &rows)
if err != nil {
return 0, nil, err
}
set, err := classes.LoadRedirectSet(db)
if err != nil {
return 0, nil, err
}
out := make([]map[string]any, 0, len(rows))
for _, row := range rows {
cover, err := classes.NotFoundCover(set, row.URL)
if err != nil {
return 0, nil, err
}
out = append(out, NotFoundJSON(row, cover))
}
return http.StatusOK, listBody(out, in, total), nil
})
}
// resolveNotFound runs a 404 log action: a 301 to target, or a 410 when
// target is empty. An address a rule already answers is 409.
func resolveNotFound(service func() *classes.Service, gone bool) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
id, err := pathID(r, "id")
if err != nil {
return 0, nil, err
}
var body struct {
Target string `json:"target"`
}
if err := decode(r, maxBody, &body); err != nil {
return 0, nil, err
}
target := strings.TrimSpace(body.Target)
if gone {
target = ""
} else if target == "" {
return 0, nil, &apiError{Status: http.StatusUnprocessableEntity, Body: map[string]any{
"error": "validation_failed", "errors": map[string][]string{"target": {svc.PhraseCtx(ctx, "golem15.seo::lang.redirects.target_required", nil)}},
}}
}
var rule models.Redirect
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
entry, err := classes.LoadNotFound(tx, id)
if err != nil {
return err
}
rule, err = svc.ResolveNotFound(ctx, tx, entry, target)
return err
})
var covered *classes.NotFoundHasRuleError
if errors.As(err, &covered) {
return 0, nil, &apiError{Status: http.StatusConflict, Body: map[string]any{
"error": "has_rule", "message": svc.PhraseCtx(ctx, "golem15.seo::lang.notfound.has_rule", map[string]string{"url": covered.URL}),
}}
}
if err != nil {
return 0, nil, err
}
return http.StatusCreated, map[string]any{"data": RedirectJSON(rule)}, nil
})
}
// RedirectNotFound serves POST /_seo/api/v1/notfound/{id}/redirect: a
// 301 from the logged address to target (origin notfound).
func RedirectNotFound(service func() *classes.Service) http.HandlerFunc {
return resolveNotFound(service, false)
}
// GoneNotFound serves POST /_seo/api/v1/notfound/{id}/gone: a 410 for the
// logged address, so search engines drop it.
func GoneNotFound(service func() *classes.Service) http.HandlerFunc {
return resolveNotFound(service, true)
}
// DeleteNotFound serves DELETE /_seo/api/v1/notfound/{id}: the entry
// leaves the log until its next 404.
func DeleteNotFound(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
id, err := pathID(r, "id")
if err != nil {
return 0, nil, err
}
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
entry, err := classes.LoadNotFound(tx, id)
if err != nil {
return err
}
return svc.DeleteNotFound(ctx, tx, entry)
})
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"deleted": id}, nil
})
}