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

240 lines
7.9 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/wire"
"gorm.io/gorm"
)
// PermissionManage is the one SEO permission every management route
// requires (D-15).
const PermissionManage = "golem15.seo.manage"
// tokenCover is the scope a personal API token must cover.
const tokenCover = "golem15.seo.*"
// maxBody caps a management request body; an import has its own cap.
const maxBody = 1 << 20
// The answers of the management API.
const (
errAuthenticationRequired = "Authentication required"
errInsufficientPerms = "Insufficient permissions"
errNotFound = "Not Found"
errInternal = "Internal Server Error"
errInvalidBody = "The request body is invalid."
)
// apiError is an error a management handler answers with its own status
// and body.
type apiError struct {
Status int
Body any
}
func (e *apiError) Error() string { return http.StatusText(e.Status) }
// Fail answers status with {"error": message}.
func Fail(status int, message string) error {
return &apiError{Status: status, Body: map[string]any{"error": message}}
}
// handler is a management endpoint: it returns the status and the body to
// answer, or an error the wrapper maps.
type handler func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error)
// manage wraps a management endpoint (D-10, D-15, P-24): it resolves the
// backend administrator from a backend JWT, a personal API token or the
// in-process MCP dispatch marker (cabana.ResolveBackend, so an MCP call runs
// as the linked administrator), refuses 401 without one and 403 without
// golem15.seo.manage, and maps the service's typed refusals: a redirect
// loop is 422 {"error":"redirect_loop","path":[...]}, field problems 422
// {"error":"validation_failed","errors":{...}}, a missing row 404. Every
// answer carries X-Robots-Tag: noindex.
func manage(service func() *classes.Service, h handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Robots-Tag", "noindex")
svc := service()
if svc == nil {
wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "SEO is not available"})
return
}
out, principal, err := cabana.ResolveBackend(svc.App(), r, tokenCover)
if err != nil || principal == nil {
if errors.Is(err, cabana.ErrTokenScope) {
wire.WriteJSON(w, http.StatusForbidden, map[string]string{"error": errInsufficientPerms})
return
}
wire.WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": errAuthenticationRequired})
return
}
if !cabana.Allows(principal, []string{PermissionManage}) {
wire.WriteJSON(w, http.StatusForbidden, map[string]string{"error": errInsufficientPerms})
return
}
status, body, err := h(out.Context(), svc, out)
if err != nil {
writeError(w, out.Context(), svc, err)
return
}
if status == http.StatusNoContent {
w.WriteHeader(status)
return
}
wire.WriteJSON(w, status, body)
}
}
// writeError maps a handler error to its answer.
func writeError(w http.ResponseWriter, ctx context.Context, svc *classes.Service, err error) {
var api *apiError
var loop *classes.LoopError
var problems *classes.ProblemsError
switch {
case errors.As(err, &api):
wire.WriteJSON(w, api.Status, api.Body)
case errors.As(err, &loop):
wire.WriteJSON(w, http.StatusUnprocessableEntity, LoopBody(ctx, svc, loop))
case errors.As(err, &problems):
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{
"error": "validation_failed",
"errors": ProblemMessages(ctx, svc, problems),
})
case errors.Is(err, classes.ErrBadURL):
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": err.Error()})
case errors.Is(err, classes.ErrNoRecord), errors.Is(err, gorm.ErrRecordNotFound):
wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": errNotFound})
default:
log.Error("golem15.seo: management API", "error", err)
wire.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": errInternal})
}
}
// LoopBody is the answer of a refused redirect loop (D-19): the error
// code, the loop's path and the message in the request locale.
func LoopBody(ctx context.Context, svc *classes.Service, loop *classes.LoopError) map[string]any {
return map[string]any{
"error": "redirect_loop",
"path": loop.Check.Path,
"message": svc.LoopMessage(ctx, loop.Check),
}
}
// ProblemMessages translates field problems in the request locale.
func ProblemMessages(ctx context.Context, svc *classes.Service, problems *classes.ProblemsError) map[string][]string {
out := map[string][]string{}
for field, list := range problems.Problems {
for _, p := range list {
out[field] = append(out[field], svc.PhraseCtx(ctx, p.Key, p.Params))
}
}
return out
}
// decode reads a JSON body of at most limit bytes into v; an empty body
// leaves v as it is.
func decode(r *http.Request, limit int64, v any) error {
if r.Body == nil {
return nil
}
dec := json.NewDecoder(io.LimitReader(r.Body, limit+1))
if err := dec.Decode(v); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return Fail(http.StatusUnprocessableEntity, errInvalidBody)
}
return nil
}
// pathID reads the {name} segment as a positive id.
func pathID(r *http.Request, name string) (uint, error) {
n, err := strconv.ParseUint(strings.TrimSpace(r.PathValue(name)), 10, 64)
if err != nil || n == 0 {
return 0, Fail(http.StatusNotFound, errNotFound)
}
return uint(n), nil
}
// write runs fn in one transaction on the application's database, with
// the transaction on ctx (classes.WithTx) so every read of the service
// sees the write.
func write(ctx context.Context, svc *classes.Service, fn func(ctx context.Context, tx *gorm.DB) error) error {
gdb, err := svc.GormDB()
if err != nil {
return err
}
return lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error {
return fn(classes.WithTx(ctx, tx), tx)
})
}
// read returns the database for a read handler.
func read(ctx context.Context, svc *classes.Service) (*gorm.DB, error) {
return svc.DB(ctx)
}
// The page sizes of the list endpoints.
const (
defaultPerPage = 50
maxPerPage = 200
)
// ListInput is the paging and search query every list tool shares.
type ListInput 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 or value contains"`
}
// listParams reads page, per_page and search.
func listParams(r *http.Request) ListInput {
q := r.URL.Query()
in := ListInput{Search: strings.TrimSpace(q.Get("search"))}
in.Page, _ = strconv.Atoi(q.Get("page"))
in.PerPage, _ = strconv.Atoi(q.Get("per_page"))
if in.Page < 1 {
in.Page = 1
}
if in.PerPage < 1 {
in.PerPage = defaultPerPage
}
if in.PerPage > maxPerPage {
in.PerPage = maxPerPage
}
return in
}
// paginate counts q and loads one page of it into dest.
func paginate(q *gorm.DB, in ListInput, dest any) (int64, error) {
var total int64
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
return 0, err
}
if err := q.Offset((in.Page - 1) * in.PerPage).Limit(in.PerPage).Find(dest).Error; err != nil {
return 0, err
}
return total, nil
}
// listBody is a list answer: the rows and the paging.
func listBody(rows any, in ListInput, total int64) map[string]any {
return map[string]any{"data": rows, "page": in.Page, "per_page": in.PerPage, "total": total}
}
// like is a case-insensitive LIKE pattern of text.
func like(text string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return "%" + strings.ToLower(r.Replace(text)) + "%"
}