- PUT /_seo/api/v1/seo writes to the record behind a URL in its locale, else to an exact URL rule (force_rule writes a rule); the answer says where (D-11) - management routes resolve the backend administrator from a token or the MCP dispatch and require golem15.seo.manage (D-10, D-15) - MCP tool set_url_seo; history records via mcp - classes.WithTx, FindOwner, SetURLSEO, SaveRule and DeleteRule
186 lines
6.2 KiB
Go
186 lines
6.2 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)
|
|
}
|