Files
sm-seo-plugin/controllers/api/redirects.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

248 lines
8.7 KiB
Go

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
})
}