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:
247
controllers/api/redirects.go
Normal file
247
controllers/api/redirects.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ListRedirectsInput is the query of GET /_seo/api/v1/redirects.
|
||||
type ListRedirectsInput 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 source or target contains"`
|
||||
Status int `json:"status,omitempty" query:"status" jsonschema:"301, 302 or 410"`
|
||||
Origin string `json:"origin,omitempty" query:"origin" jsonschema:"manual, slug, import, mcp or notfound"`
|
||||
}
|
||||
|
||||
// RedirectIDInput names one redirect.
|
||||
type RedirectIDInput struct {
|
||||
ID uint `json:"id" path:"id" jsonschema:"the redirect id"`
|
||||
}
|
||||
|
||||
// CreateRedirectInput is the body of POST /_seo/api/v1/redirects.
|
||||
type CreateRedirectInput struct {
|
||||
Source string `json:"source" jsonschema:"the address to redirect: a path (exact), a pattern where * matches any text, or a regular expression"`
|
||||
Target *string `json:"target,omitempty" jsonschema:"where to send visitors: a path or an absolute URL; empty for 410"`
|
||||
Status *int `json:"status,omitempty" jsonschema:"301 (the default), 302 or 410"`
|
||||
Match *string `json:"match,omitempty" jsonschema:"exact (the default), pattern or regex"`
|
||||
Query *string `json:"query,omitempty" jsonschema:"ignore (the default) or pass the request's query string to the target"`
|
||||
IgnoreCase *bool `json:"ignore_case,omitempty" jsonschema:"match the source without letter case"`
|
||||
TrailingSlash *bool `json:"trailing_slash,omitempty" jsonschema:"match the source with and without a trailing slash (default true)"`
|
||||
Note *string `json:"note,omitempty" jsonschema:"an internal note"`
|
||||
}
|
||||
|
||||
// UpdateRedirectInput is PUT /_seo/api/v1/redirects/{id}: only the fields
|
||||
// sent are changed.
|
||||
type UpdateRedirectInput struct {
|
||||
ID uint `json:"id" path:"id" jsonschema:"the redirect id"`
|
||||
Source *string `json:"source,omitempty" jsonschema:"the address to redirect"`
|
||||
Target *string `json:"target,omitempty" jsonschema:"where to send visitors; empty for 410"`
|
||||
Status *int `json:"status,omitempty" jsonschema:"301, 302 or 410"`
|
||||
Match *string `json:"match,omitempty" jsonschema:"exact, pattern or regex"`
|
||||
Query *string `json:"query,omitempty" jsonschema:"ignore or pass"`
|
||||
IgnoreCase *bool `json:"ignore_case,omitempty" jsonschema:"match without letter case"`
|
||||
TrailingSlash *bool `json:"trailing_slash,omitempty" jsonschema:"match with and without a trailing slash"`
|
||||
Note *string `json:"note,omitempty" jsonschema:"an internal note"`
|
||||
}
|
||||
|
||||
// RedirectBody is the decoded body of a redirect write; the batch and the
|
||||
// import use it too.
|
||||
type RedirectBody struct {
|
||||
Source *string `json:"source,omitempty"`
|
||||
Target *string `json:"target,omitempty"`
|
||||
Status *int `json:"status,omitempty"`
|
||||
Match *string `json:"match,omitempty"`
|
||||
Query *string `json:"query,omitempty"`
|
||||
IgnoreCase *bool `json:"ignore_case,omitempty"`
|
||||
TrailingSlash *bool `json:"trailing_slash,omitempty"`
|
||||
Note *string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// Apply writes the fields sent onto r.
|
||||
func (b RedirectBody) Apply(r *models.Redirect) {
|
||||
if b.Source != nil {
|
||||
r.Source = strings.TrimSpace(*b.Source)
|
||||
}
|
||||
if b.Target != nil {
|
||||
r.Target = strings.TrimSpace(*b.Target)
|
||||
}
|
||||
if b.Status != nil {
|
||||
r.Status = models.RedirectStatus(*b.Status)
|
||||
}
|
||||
if b.Match != nil {
|
||||
r.Match = strings.TrimSpace(*b.Match)
|
||||
}
|
||||
if b.Query != nil {
|
||||
r.Query = strings.TrimSpace(*b.Query)
|
||||
}
|
||||
if b.IgnoreCase != nil {
|
||||
r.IgnoreCase = *b.IgnoreCase
|
||||
}
|
||||
if b.TrailingSlash != nil {
|
||||
r.TrailingSlash = *b.TrailingSlash
|
||||
}
|
||||
if b.Note != nil {
|
||||
r.Note = strings.TrimSpace(*b.Note)
|
||||
}
|
||||
}
|
||||
|
||||
// RedirectJSON is a redirect in an answer.
|
||||
func RedirectJSON(r models.Redirect) map[string]any {
|
||||
out := classes.RedirectValues(r)
|
||||
out["status"] = int(r.Status)
|
||||
out["id"] = r.ID
|
||||
out["created_at"] = r.CreatedAt
|
||||
out["updated_at"] = r.UpdatedAt
|
||||
return out
|
||||
}
|
||||
|
||||
// redirectAnswer is a write's answer: the redirect and, for a chain, the
|
||||
// chain warning (D-19: a chain is saved, a loop refused).
|
||||
func redirectAnswer(ctx context.Context, svc *classes.Service, r models.Redirect, check classes.RedirectCheck) map[string]any {
|
||||
out := map[string]any{"data": RedirectJSON(r)}
|
||||
if check.Chain {
|
||||
notice := classes.ChainNotice(check)[0]
|
||||
out["warnings"] = []map[string]any{{
|
||||
"kind": "redirect_chain",
|
||||
"path": check.Path,
|
||||
"final": check.Final,
|
||||
"message": svc.PhraseCtx(ctx, notice.Message, notice.Params),
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListRedirects serves GET /_seo/api/v1/redirects: newest change first,
|
||||
// filtered by search text, status and origin.
|
||||
func ListRedirects(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.Redirect{}).Order("updated_at DESC NULLS LAST, id DESC")
|
||||
if in.Search != "" {
|
||||
p := like(in.Search)
|
||||
q = q.Where("lower(source) LIKE ? OR lower(target) LIKE ?", p, p)
|
||||
}
|
||||
if s, err := strconv.Atoi(r.URL.Query().Get("status")); err == nil && s > 0 {
|
||||
q = q.Where("status = ?", s)
|
||||
}
|
||||
if o := r.URL.Query().Get("origin"); o != "" {
|
||||
q = q.Where("origin = ?", o)
|
||||
}
|
||||
var rows []models.Redirect
|
||||
total, err := paginate(q, in, &rows)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, RedirectJSON(row))
|
||||
}
|
||||
return http.StatusOK, listBody(out, in, total), nil
|
||||
})
|
||||
}
|
||||
|
||||
// ShowRedirect serves GET /_seo/api/v1/redirects/{id}.
|
||||
func ShowRedirect(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
|
||||
}
|
||||
db, err := read(ctx, svc)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
row, err := classes.LoadRedirect(db, id)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return http.StatusOK, map[string]any{"data": RedirectJSON(row)}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// CreateRedirect serves POST /_seo/api/v1/redirects: 201 with the
|
||||
// redirect (and a chain warning), 422 redirect_loop for a loop (D-19) or
|
||||
// validation_failed for invalid fields. The origin is mcp through MCP.
|
||||
func CreateRedirect(service func() *classes.Service) http.HandlerFunc {
|
||||
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
||||
var body RedirectBody
|
||||
if err := decode(r, maxBody, &body); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
row := models.NewRedirect()
|
||||
row.Origin = classes.RedirectOrigin(ctx)
|
||||
body.Apply(&row)
|
||||
var check classes.RedirectCheck
|
||||
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
||||
var err error
|
||||
check, err = svc.SaveRedirect(ctx, tx, &row)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return http.StatusCreated, redirectAnswer(ctx, svc, row, check), nil
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRedirect serves PUT /_seo/api/v1/redirects/{id}: the fields sent
|
||||
// change; a loop is refused like a create.
|
||||
func UpdateRedirect(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
|
||||
}
|
||||
var body RedirectBody
|
||||
if err := decode(r, maxBody, &body); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
var row models.Redirect
|
||||
var check classes.RedirectCheck
|
||||
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
||||
var err error
|
||||
if row, err = classes.LoadRedirect(tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
body.Apply(&row)
|
||||
check, err = svc.SaveRedirect(ctx, tx, &row)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return http.StatusOK, redirectAnswer(ctx, svc, row, check), nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteRedirect serves DELETE /_seo/api/v1/redirects/{id}.
|
||||
func DeleteRedirect(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 {
|
||||
row, err := classes.LoadRedirect(tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.DeleteRedirect(ctx, tx, row)
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return http.StatusOK, map[string]any{"deleted": id}, nil
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user