- /_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
240 lines
7.9 KiB
Go
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)) + "%"
|
|
}
|