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:
193
classes/owner.go
Normal file
193
classes/owner.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Where a change addressed by URL is written (D-11).
|
||||
const (
|
||||
WrittenToRecord = "record"
|
||||
WrittenToRule = "rule"
|
||||
)
|
||||
|
||||
// Owner is the owner of a URL's SEO (D-11, P-25): the record behind the
|
||||
// URL in the matched locale, or an exact URL rule. URL is the address in
|
||||
// the site's form (normalised path with its query); RuleID is the rule's
|
||||
// id once it exists. Published is true for a published record.
|
||||
type Owner struct {
|
||||
WrittenTo string `json:"written_to"`
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
RuleID uint `json:"rule_id,omitempty"`
|
||||
Published bool `json:"-"`
|
||||
}
|
||||
|
||||
// FindOwner resolves the owner of rawURL (a path or an absolute URL of the
|
||||
// site) inside db: the URL is normalised with the site's address rules,
|
||||
// then the signpost patterns (MatchPath) name the record behind it. An
|
||||
// exact match without a query is the record's SEO in the matched locale;
|
||||
// anything else, or forceRule, is an exact URL rule on the normalised
|
||||
// address. ErrBadURL refuses an input that is not a path or http(s) URL.
|
||||
func (s *Service) FindOwner(ctx context.Context, db *gorm.DB, rawURL string, forceRule bool) (Owner, pact.MenuItemType, error) {
|
||||
path, _, err := parseTarget(rawURL)
|
||||
if err != nil {
|
||||
return Owner{}, pact.MenuItemType{}, err
|
||||
}
|
||||
settings, err := LoadSettings(db)
|
||||
if err != nil {
|
||||
return Owner{}, pact.MenuItemType{}, err
|
||||
}
|
||||
norm, _ := Normalize(&url.URL{Path: path, RawQuery: rawQueryOf(rawURL)}, NormalizeOptionsOf(settings))
|
||||
address := joinQuery(norm.Path, norm.RawQuery)
|
||||
if !forceRule && norm.RawQuery == "" {
|
||||
if reg := s.Registry(); reg != nil {
|
||||
unprefixed := s.UnprefixedLocale()
|
||||
key, match, err := reg.MatchPath(ctx, trimSlash(norm.Path), unprefixed)
|
||||
if err != nil {
|
||||
return Owner{}, pact.MenuItemType{}, err
|
||||
}
|
||||
if match != nil && match.Exact {
|
||||
typ, ok := reg.ContentType(key)
|
||||
if ok && typ.Content != nil {
|
||||
owner := Owner{WrittenTo: WrittenToRecord, URL: address, Type: key, ID: match.ID, Locale: match.Locale}
|
||||
if rec, err := reg.Describe(ctx, key, match.ID, unprefixed); err == nil && rec != nil {
|
||||
owner.Published = rec.Published
|
||||
}
|
||||
return owner, typ, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
owner := Owner{WrittenTo: WrittenToRule, URL: address}
|
||||
rule, found, err := ExactRule(db, address)
|
||||
if err != nil {
|
||||
return Owner{}, pact.MenuItemType{}, err
|
||||
}
|
||||
if found {
|
||||
owner.RuleID = rule.ID
|
||||
}
|
||||
return owner, pact.MenuItemType{}, nil
|
||||
}
|
||||
|
||||
// ExactRule returns the exact URL rule of address; found is false when
|
||||
// there is none.
|
||||
func ExactRule(db *gorm.DB, address string) (models.Rule, bool, error) {
|
||||
var rows []models.Rule
|
||||
if err := db.Where("url = ? AND match = ?", address, models.MatchExact).Limit(1).Find(&rows).Error; err != nil {
|
||||
return models.Rule{}, false, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return models.Rule{}, false, nil
|
||||
}
|
||||
return rows[0], true, nil
|
||||
}
|
||||
|
||||
// URLSEOWrite is one change of a URL's SEO (D-11): the address, the
|
||||
// locale of a record's entry (empty takes the locale the URL matched),
|
||||
// the values to write (column -> value; an absent column is left as it
|
||||
// is, an empty value inherits), and ForceRule to write a URL rule even
|
||||
// when a record is behind the URL.
|
||||
type URLSEOWrite struct {
|
||||
URL string
|
||||
Locale string
|
||||
Values map[string]string
|
||||
ForceRule bool
|
||||
}
|
||||
|
||||
// URLSEOResult is where a URL change was written and the stored values
|
||||
// of the owner after the write.
|
||||
type URLSEOResult struct {
|
||||
Owner
|
||||
Values map[string]string `json:"values"`
|
||||
}
|
||||
|
||||
// SetURLSEO writes in.Values to the owner of in.URL inside tx (D-11): the
|
||||
// record's SEO entry when a record is behind the URL, else an exact URL
|
||||
// rule, created when missing. Robots values are brought to the C5 grammar,
|
||||
// a canonical must be a path or an http(s) URL, and the focus keyphrase
|
||||
// belongs to records only; problems are refused with a *ProblemsError.
|
||||
// History records the write with the via of ctx.
|
||||
func (s *Service) SetURLSEO(ctx context.Context, tx *gorm.DB, in URLSEOWrite) (URLSEOResult, error) {
|
||||
owner, typ, err := s.FindOwner(ctx, tx, in.URL, in.ForceRule)
|
||||
if err != nil {
|
||||
return URLSEOResult{}, err
|
||||
}
|
||||
values := map[string]string{}
|
||||
problems := map[string][]models.Problem{}
|
||||
known := map[string]bool{}
|
||||
for _, column := range EntryColumns {
|
||||
known[column] = true
|
||||
}
|
||||
for column, raw := range in.Values {
|
||||
v := strings.TrimSpace(raw)
|
||||
switch {
|
||||
case !known[column]:
|
||||
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.field_unknown"})
|
||||
continue
|
||||
case column == "keyphrase" && owner.WrittenTo == WrittenToRule && v != "":
|
||||
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.keyphrase_record_only"})
|
||||
continue
|
||||
case column == "robots" && v != "":
|
||||
normal, err := NormalizeRobots(v)
|
||||
if err != nil {
|
||||
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.robots.invalid"})
|
||||
continue
|
||||
}
|
||||
v = normal
|
||||
case column == "canonical" && v != "" && !ValidURLRef(v):
|
||||
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.canonical_invalid"})
|
||||
continue
|
||||
}
|
||||
values[column] = v
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return URLSEOResult{}, &ProblemsError{Problems: problems}
|
||||
}
|
||||
if owner.WrittenTo == WrittenToRecord {
|
||||
locale := owner.Locale
|
||||
if l := strings.TrimSpace(in.Locale); l != "" {
|
||||
locale = l
|
||||
}
|
||||
owner.Locale = locale
|
||||
write := RecordValues{}
|
||||
for column, v := range values {
|
||||
write[column] = map[string]string{locale: v}
|
||||
}
|
||||
_, after, err := s.SaveRecord(ctx, tx, RecordWrite{Type: typ, ID: owner.ID, Values: write})
|
||||
if err != nil {
|
||||
return URLSEOResult{}, err
|
||||
}
|
||||
out := map[string]string{}
|
||||
e := after[locale]
|
||||
for _, column := range EntryColumns {
|
||||
out[column] = EntryValue(e, column)
|
||||
}
|
||||
return URLSEOResult{Owner: owner, Values: out}, nil
|
||||
}
|
||||
rule := models.Rule{URL: owner.URL, Match: models.MatchExact}
|
||||
if owner.RuleID != 0 {
|
||||
if rule, err = LoadRule(tx, owner.RuleID); err != nil {
|
||||
return URLSEOResult{}, err
|
||||
}
|
||||
}
|
||||
for column, v := range values {
|
||||
SetRuleValue(&rule, column, v)
|
||||
}
|
||||
if err := s.SaveRule(ctx, tx, &rule); err != nil {
|
||||
return URLSEOResult{}, err
|
||||
}
|
||||
owner.RuleID = rule.ID
|
||||
out := map[string]string{}
|
||||
for _, p := range models.RuleProperties {
|
||||
out[p.Column] = rule.Value(p.Column)
|
||||
}
|
||||
return URLSEOResult{Owner: owner, Values: out}, nil
|
||||
}
|
||||
118
classes/rules.go
Normal file
118
classes/rules.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ErrNoRecord is returned for an id that names no row.
|
||||
var ErrNoRecord = errors.New("golem15.seo: record not found")
|
||||
|
||||
// LoadRule returns rule id; ErrNoRecord when there is none.
|
||||
func LoadRule(db *gorm.DB, id uint) (models.Rule, error) {
|
||||
var rows []models.Rule
|
||||
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
|
||||
return models.Rule{}, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return models.Rule{}, ErrNoRecord
|
||||
}
|
||||
return rows[0], nil
|
||||
}
|
||||
|
||||
// SaveRule validates and stores r inside tx and records it in history: the
|
||||
// address is trimmed and the match normalised, robots values are brought
|
||||
// to the C5 grammar and a canonical must be a path or an http(s) URL. Field
|
||||
// problems are refused with a *ProblemsError.
|
||||
func (s *Service) SaveRule(ctx context.Context, tx *gorm.DB, r *models.Rule) error {
|
||||
if r == nil {
|
||||
return errors.New("golem15.seo: no rule")
|
||||
}
|
||||
r.URL = strings.TrimSpace(r.URL)
|
||||
r.Match = models.NormalizeMatch(r.Match)
|
||||
problems := r.Problems(tx)
|
||||
if v := strings.TrimSpace(r.Robots); v != "" {
|
||||
normal, err := NormalizeRobots(v)
|
||||
if err != nil {
|
||||
problems["robots"] = append(problems["robots"], models.Problem{Key: "golem15.seo::lang.robots.invalid"})
|
||||
}
|
||||
r.Robots = normal
|
||||
}
|
||||
if v := strings.TrimSpace(r.Canonical); v != "" && !ValidURLRef(v) {
|
||||
problems["canonical"] = append(problems["canonical"], models.Problem{Key: "golem15.seo::lang.api.canonical_invalid"})
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return &ProblemsError{Problems: problems}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var before map[string]any
|
||||
if r.ID == 0 {
|
||||
r.CreatedAt, r.UpdatedAt = &now, &now
|
||||
if err := tx.Create(r).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
old, err := LoadRule(tx, r.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before = RuleValues(old)
|
||||
r.CreatedAt, r.UpdatedAt = old.CreatedAt, &now
|
||||
if err := tx.Save(r).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.Record(ctx, tx, Change{
|
||||
Kind: models.SubjectRule,
|
||||
SubjectID: strconv.FormatUint(uint64(r.ID), 10),
|
||||
What: s.Phrase("golem15.seo::lang.history.what_rule", map[string]string{"url": r.URL}),
|
||||
Before: before,
|
||||
After: RuleValues(*r),
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteRule deletes r inside tx and records it in history.
|
||||
func (s *Service) DeleteRule(ctx context.Context, tx *gorm.DB, r models.Rule) error {
|
||||
if err := tx.Delete(&models.Rule{}, r.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Record(ctx, tx, Change{
|
||||
Kind: models.SubjectRule,
|
||||
SubjectID: strconv.FormatUint(uint64(r.ID), 10),
|
||||
What: s.Phrase("golem15.seo::lang.history.what_rule", map[string]string{"url": r.URL}),
|
||||
Before: RuleValues(r),
|
||||
})
|
||||
}
|
||||
|
||||
// SetRuleValue writes one value column of a rule; an unknown column is
|
||||
// ignored.
|
||||
func SetRuleValue(r *models.Rule, column, value string) {
|
||||
switch column {
|
||||
case "url":
|
||||
r.URL = value
|
||||
case "match":
|
||||
r.Match = value
|
||||
case "note":
|
||||
r.Note = value
|
||||
case "title":
|
||||
r.Title = value
|
||||
case "description":
|
||||
r.Description = value
|
||||
case "canonical":
|
||||
r.Canonical = value
|
||||
case "robots":
|
||||
r.Robots = value
|
||||
case "og_title":
|
||||
r.OGTitle = value
|
||||
case "og_description":
|
||||
r.OGDescription = value
|
||||
case "og_image":
|
||||
r.OGImage = value
|
||||
}
|
||||
}
|
||||
@@ -46,9 +46,25 @@ func (s *Service) App() *backpack.App {
|
||||
return s.app
|
||||
}
|
||||
|
||||
// DB returns the write transaction carried by ctx (cabana.TxFromContext),
|
||||
// else the application's database bound to ctx.
|
||||
type txKey struct{}
|
||||
|
||||
// WithTx returns ctx carrying tx, the transaction an API write or a batch
|
||||
// runs in, so every read of the service (the resolver, the redirect set,
|
||||
// the settings) sees the uncommitted changes of that transaction.
|
||||
func WithTx(ctx context.Context, tx *gorm.DB) context.Context {
|
||||
if tx == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, txKey{}, tx)
|
||||
}
|
||||
|
||||
// DB returns the transaction carried by ctx (WithTx, else the admin write
|
||||
// transaction of cabana.TxFromContext), else the application's database
|
||||
// bound to ctx.
|
||||
func (s *Service) DB(ctx context.Context) (*gorm.DB, error) {
|
||||
if tx, ok := ctx.Value(txKey{}).(*gorm.DB); ok && tx != nil {
|
||||
return tx, nil
|
||||
}
|
||||
if tx, ok := cabana.TxFromContext(ctx); ok && tx != nil {
|
||||
return tx, nil
|
||||
}
|
||||
@@ -71,6 +87,19 @@ func (s *Service) Registry() *signpost.Registry {
|
||||
return reg
|
||||
}
|
||||
|
||||
// GormDB returns the application's database without a transaction, the
|
||||
// handle lagoon.Transaction opens a write on.
|
||||
func (s *Service) GormDB() (*gorm.DB, error) {
|
||||
if s == nil || s.app == nil {
|
||||
return nil, ErrNotBooted
|
||||
}
|
||||
db, ok := s.app.Lookup[*gorm.DB]()
|
||||
if !ok || db == nil {
|
||||
return nil, errors.New("golem15.seo: database is not configured")
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Config returns the application config, nil when there is none.
|
||||
func (s *Service) Config() *compass.Config {
|
||||
if s == nil || s.app == nil {
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -290,3 +290,7 @@ bots:
|
||||
search_comment: "AI search and answers. Off blocks it in robots.txt."
|
||||
training_comment: "Collects content for AI training. Off blocks it in robots.txt."
|
||||
user_comment: "Fetches a page when a user asks for it. Off blocks it in robots.txt."
|
||||
api:
|
||||
field_unknown: This is not an SEO field.
|
||||
keyphrase_record_only: A focus keyphrase belongs to a record; a URL rule has none.
|
||||
canonical_invalid: The canonical URL must be a path starting with / or an absolute http(s) URL.
|
||||
|
||||
@@ -300,3 +300,7 @@ bots:
|
||||
search_comment: "Wyszukiwanie i odpowiedzi AI. Wyłączenie blokuje go w robots.txt."
|
||||
training_comment: "Zbiera treści do trenowania AI. Wyłączenie blokuje go w robots.txt."
|
||||
user_comment: "Pobiera stronę na prośbę użytkownika. Wyłączenie blokuje go w robots.txt."
|
||||
api:
|
||||
field_unknown: To nie jest pole SEO.
|
||||
keyphrase_record_only: Fraza kluczowa należy do rekordu; reguła adresu jej nie ma.
|
||||
canonical_invalid: Adres kanoniczny musi być ścieżką zaczynającą się od / lub pełnym adresem http(s).
|
||||
|
||||
38
mcp.go
Normal file
38
mcp.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package seo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
apicontrollers "git.golem15.com/golem15/sm-seo-plugin/controllers/api"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
var _ pact.HasMCP = (*Plugin)(nil)
|
||||
|
||||
// mcpGuard is the guard every SEO tool runs as: the backend account linked
|
||||
// to the MCP user. The routes resolve that administrator themselves.
|
||||
const mcpGuard = "backend"
|
||||
|
||||
// mcpPermission is the one SEO permission (D-15) every tool is listed with;
|
||||
// the routes check it on every call.
|
||||
const mcpPermission = apicontrollers.PermissionManage
|
||||
|
||||
// MCP declares the SEO tools (D-10, P-24): one per operation of the SEO
|
||||
// admin, each bound to a /_seo/api/v1 management route, run as the backend
|
||||
// account linked to the MCP user and listed only when that account has
|
||||
// golem15.seo.manage. pier prefixes them with seo_. GET tools are
|
||||
// read-only, so a read grant sees only those.
|
||||
func (p *Plugin) MCP() pact.MCP {
|
||||
return pact.MCP{Tools: []pact.MCPTool{
|
||||
{
|
||||
Name: "set_url_seo",
|
||||
Title: "Set the SEO of a URL",
|
||||
Description: "Changes the SEO values of a page addressed by URL. A record behind the URL gets the change in its SEO tab in the URL's locale; otherwise, or with force_rule, an exact URL rule is written. The answer says where it was written.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodPut,
|
||||
Path: "/_seo/api/v1/seo",
|
||||
Input: apicontrollers.SetSEOInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
}}
|
||||
}
|
||||
148
mcp_test.go
Normal file
148
mcp_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package seo_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
)
|
||||
|
||||
// principal resolves the backend administrator of an access token, the
|
||||
// identity an MCP session acts as.
|
||||
func (e *testEnv) principal(t *testing.T, token string) *bouncer.Principal {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
_, p, err := cabana.ResolveBackend(e.app, req)
|
||||
if err != nil || p == nil {
|
||||
t.Fatalf("resolve principal: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// mcp runs one request the way pier dispatches a tool call: in-process,
|
||||
// without an Authorization header, with the backend dispatch marker.
|
||||
func (e *testEnv) mcp(t *testing.T, method, path string, p *bouncer.Principal, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", seq.Add(1)%250+1)
|
||||
req.Header.Set("Accept-Language", "en")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req = req.WithContext(bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: p}))
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// mcpExpect runs an MCP-dispatched request and fails unless it answers
|
||||
// status; it returns the decoded JSON body.
|
||||
func (e *testEnv) mcpExpect(t *testing.T, status int, method, path string, p *bouncer.Principal, body any) map[string]any {
|
||||
t.Helper()
|
||||
rec := e.mcp(t, method, path, p, body)
|
||||
if rec.Code != status {
|
||||
t.Fatalf("MCP %s %s = %d, want %d: %s", method, path, rec.Code, status, rec.Body.String())
|
||||
}
|
||||
out := map[string]any{}
|
||||
if rec.Body.Len() > 0 && strings.HasPrefix(strings.TrimSpace(rec.Body.String()), "{") {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestMCPSetURLSeoTracer is the end-to-end slice of D-10/D-11: an MCP
|
||||
// client (an administrator dispatched for the backend guard) sets noindex
|
||||
// on a URL a record is behind; the change lands in the record's SEO,
|
||||
// resolve serves it as robots and X-Robots-Tag, and history records it
|
||||
// with via mcp. A URL without a record gets an exact URL rule, and an
|
||||
// administrator without golem15.seo.manage is refused.
|
||||
func TestMCPSetURLSeoTracer(t *testing.T) {
|
||||
env := bootEnv(t)
|
||||
token := env.admin(t, true)
|
||||
id := env.createPage(t, token, map[string]any{"title": "Page A", "slug": "a", "excerpt": "About A", "published": true})
|
||||
agent := env.principal(t, env.admin(t, false, "golem15.seo.manage"))
|
||||
|
||||
got := env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{
|
||||
"url": "/pages/a", "values": map[string]any{"robots": "noindex"},
|
||||
})
|
||||
if got["written_to"] != "record" || got["type"] != pagesTypeKey || got["id"] != id || got["locale"] != "en" {
|
||||
t.Fatalf("set record = %v, want written_to record %s/%s en", got, pagesTypeKey, id)
|
||||
}
|
||||
|
||||
res := env.resolve(t, "/pages/a")
|
||||
seoBody, _ := res["seo"].(map[string]any)
|
||||
if res["action"] != "ok" || !strings.HasPrefix(fmt.Sprint(seoBody["robots"]), "noindex") || !strings.HasPrefix(fmt.Sprint(seoBody["x_robots_tag"]), "noindex") {
|
||||
t.Fatalf("resolve /pages/a = %v, want robots and x_robots_tag noindex", res)
|
||||
}
|
||||
|
||||
revs := env.revisions(t, "entry")
|
||||
if len(revs) != 1 || revs[0].Via != "mcp" || !strings.HasPrefix(revs[0].AuthorLabel, "MCP: ") || !strings.Contains(revs[0].After, "noindex") {
|
||||
t.Fatalf("entry revisions = %+v, want one via mcp with the MCP author and noindex", revs)
|
||||
}
|
||||
|
||||
got = env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{
|
||||
"url": "/landing", "values": map[string]any{"title": "Landing", "robots": "noindex,follow"},
|
||||
})
|
||||
ruleID, _ := got["rule_id"].(float64)
|
||||
if got["written_to"] != "rule" || ruleID == 0 || got["url"] != "/landing" {
|
||||
t.Fatalf("set /landing = %v, want written_to rule with a rule id", got)
|
||||
}
|
||||
var rules []struct {
|
||||
URL, Match, Title, Robots string
|
||||
}
|
||||
if err := env.db.Raw(`SELECT url, match, title, robots FROM golem15_seo_rules`).Scan(&rules).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rules) != 1 || rules[0].URL != "/landing" || rules[0].Match != "exact" || rules[0].Title != "Landing" {
|
||||
t.Fatalf("rules = %+v, want one exact /landing rule", rules)
|
||||
}
|
||||
if revs := env.revisions(t, "rule"); len(revs) != 1 || revs[0].Via != "mcp" {
|
||||
t.Fatalf("rule revisions = %+v, want one via mcp", revs)
|
||||
}
|
||||
res = env.resolve(t, "/landing")
|
||||
seoBody, _ = res["seo"].(map[string]any)
|
||||
if res["action"] != "ok" || seoBody["title"] != "Landing" {
|
||||
t.Fatalf("resolve /landing = %v, want the rule's title", res)
|
||||
}
|
||||
|
||||
// force_rule writes a rule even with a record behind the URL.
|
||||
got = env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{
|
||||
"url": "/pages/a", "values": map[string]any{"title": "Forced"}, "force_rule": true,
|
||||
})
|
||||
if got["written_to"] != "rule" {
|
||||
t.Fatalf("force_rule = %v, want written_to rule", got)
|
||||
}
|
||||
|
||||
// Without golem15.seo.manage: 403 over MCP and over a bearer token.
|
||||
plain := env.admin(t, false)
|
||||
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/seo", env.principal(t, plain), map[string]any{"url": "/pages/a", "values": map[string]any{"robots": "index"}}); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("MCP without the permission = %d %s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := env.call(t, http.MethodPut, "/_seo/api/v1/seo", plain, map[string]any{"url": "/pages/a", "values": map[string]any{"robots": "index"}}); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("bearer without the permission = %d %s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := env.call(t, http.MethodPut, "/_seo/api/v1/seo", "", map[string]any{"url": "/pages/a"}); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous = %d, want 401", rec.Code)
|
||||
}
|
||||
if revs := env.revisions(t, "entry"); len(revs) != 1 {
|
||||
t.Fatalf("refused writes changed history: %+v", revs)
|
||||
}
|
||||
}
|
||||
21
routes.go
21
routes.go
@@ -18,12 +18,17 @@ const (
|
||||
BucketNotFound = "seo-notfound"
|
||||
// BucketRobots is the /robots.txt bucket.
|
||||
BucketRobots = "seo-robots"
|
||||
// BucketAPI is the management API's bucket (P-24).
|
||||
BucketAPI = "seo-api"
|
||||
)
|
||||
|
||||
// Routes mounts GET /robots.txt at the site root and the /_seo/api/v1
|
||||
// endpoints. None carries backend middleware: crawlers and the frontend
|
||||
// server call them anonymously (the 404 report with its own token). Every
|
||||
// /_seo/api/v1 answer carries X-Robots-Tag: noindex.
|
||||
// Routes mounts GET /robots.txt at the site root, the public /_seo/api/v1
|
||||
// endpoints and the management API. The public ones carry no backend
|
||||
// middleware: crawlers and the frontend server call them anonymously (the
|
||||
// 404 report with its own token). The management routes (bucket seo-api)
|
||||
// resolve the administrator themselves from a backend token or the MCP
|
||||
// dispatch and require golem15.seo.manage (D-10, D-15); each one is an MCP
|
||||
// tool (mcp.go). Every /_seo/api/v1 answer carries X-Robots-Tag: noindex.
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
r.Get("/robots.txt", apicontrollers.Robots(p.Service), "throttle:"+BucketRobots)
|
||||
r.Group("/_seo/api/v1", nil, func(g pact.Router) {
|
||||
@@ -35,6 +40,10 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
g.Get("/verification/{name}", noindex(apicontrollers.Verification(p.Service)), "throttle:"+BucketResolve)
|
||||
g.Where("name", classes.VerificationNamePattern)
|
||||
})
|
||||
r.Group("/_seo/api/v1", nil, func(g pact.Router) {
|
||||
api := "throttle:" + BucketAPI
|
||||
g.Put("/seo", apicontrollers.SetSEO(p.Service), api)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -49,7 +58,8 @@ func noindex(h http.HandlerFunc) http.HandlerFunc {
|
||||
// Buckets registers the per-client-IP buckets behind the configured
|
||||
// trusted proxies: seo-resolve 600 a minute (the frontend server resolves
|
||||
// every page it renders), seo-notfound 600 a minute (one report per 404
|
||||
// page) and seo-robots 120 a minute.
|
||||
// page), seo-robots 120 a minute and seo-api 300 a minute (an MCP agent
|
||||
// working through an audit).
|
||||
func (p *Plugin) Buckets() map[string]surf.Bucket {
|
||||
trusted := surf.TrustedProxies(nil)
|
||||
if p.app != nil {
|
||||
@@ -69,5 +79,6 @@ func (p *Plugin) Buckets() map[string]surf.Bucket {
|
||||
BucketResolve: bucket(BucketResolve, 600),
|
||||
BucketNotFound: bucket(BucketNotFound, 600),
|
||||
BucketRobots: bucket(BucketRobots, 120),
|
||||
BucketAPI: bucket(BucketAPI, 300),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user