feat: SEO by URL over the management API and MCP (tracer)
- 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
This commit is contained in:
185
controllers/api/auth.go
Normal file
185
controllers/api/auth.go
Normal file
@@ -0,0 +1,185 @@
|
||||
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)
|
||||
}
|
||||
71
controllers/api/seo.go
Normal file
71
controllers/api/seo.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SEOValues are the SEO values of a URL in a request: a value that is
|
||||
// absent is left as it is, an empty one inherits.
|
||||
type SEOValues struct {
|
||||
Title *string `json:"title,omitempty" jsonschema:"the SEO title"`
|
||||
Description *string `json:"description,omitempty" jsonschema:"the meta description"`
|
||||
Keyphrase *string `json:"keyphrase,omitempty" jsonschema:"the focus keyphrase (records only)"`
|
||||
Canonical *string `json:"canonical,omitempty" jsonschema:"the canonical URL: a path or an absolute http(s) URL"`
|
||||
Robots *string `json:"robots,omitempty" jsonschema:"robots directives, for example noindex,follow"`
|
||||
OGTitle *string `json:"og_title,omitempty" jsonschema:"the social title"`
|
||||
OGDescription *string `json:"og_description,omitempty" jsonschema:"the social description"`
|
||||
OGImage *string `json:"og_image,omitempty" jsonschema:"the social image: a path or an absolute URL"`
|
||||
}
|
||||
|
||||
// Map returns the values that are set, keyed by column.
|
||||
func (v SEOValues) Map() map[string]string {
|
||||
out := map[string]string{}
|
||||
for column, p := range map[string]*string{
|
||||
"title": v.Title, "description": v.Description, "keyphrase": v.Keyphrase,
|
||||
"canonical": v.Canonical, "robots": v.Robots, "og_title": v.OGTitle,
|
||||
"og_description": v.OGDescription, "og_image": v.OGImage,
|
||||
} {
|
||||
if p != nil {
|
||||
out[column] = *p
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetSEOInput is the body of PUT /_seo/api/v1/seo.
|
||||
type SetSEOInput struct {
|
||||
URL string `json:"url" jsonschema:"the page: a path such as /blog/post or an absolute URL of the site"`
|
||||
Locale string `json:"locale,omitempty" jsonschema:"the locale of a record's SEO; the locale the URL matches when omitted"`
|
||||
Values SEOValues `json:"values" jsonschema:"the values to change"`
|
||||
ForceRule bool `json:"force_rule,omitempty" jsonschema:"write a URL rule even when a record is behind the URL"`
|
||||
}
|
||||
|
||||
// SetSEO serves PUT /_seo/api/v1/seo (D-11, P-25): the values go to the
|
||||
// record behind the URL in its locale, else to an exact URL rule (created
|
||||
// when missing), or to a rule when force_rule is set. It answers 200 with
|
||||
// where the change was written ({"written_to":"record"|"rule", "type",
|
||||
// "id", "locale", "rule_id"}) and the stored values; 422 for a URL that is
|
||||
// not a path or http(s) URL or for invalid values. History records the
|
||||
// write with via mcp when it comes through MCP.
|
||||
func SetSEO(service func() *classes.Service) http.HandlerFunc {
|
||||
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
||||
var in SetSEOInput
|
||||
if err := decode(r, maxBody, &in); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
var res classes.URLSEOResult
|
||||
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
||||
var err error
|
||||
res, err = svc.SetURLSEO(ctx, tx, classes.URLSEOWrite{URL: in.URL, Locale: in.Locale, Values: in.Values.Map(), ForceRule: in.ForceRule})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return http.StatusOK, res, nil
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user