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