From cadf6ad805818deb94bdfde1974ec3d72e2574d0 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 27 Oct 2026 12:06:31 +0100 Subject: [PATCH] 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 --- classes/owner.go | 193 ++++++++++++++++++++++++++++++++++++++++ classes/rules.go | 118 ++++++++++++++++++++++++ classes/service.go | 33 ++++++- controllers/api/auth.go | 185 ++++++++++++++++++++++++++++++++++++++ controllers/api/seo.go | 71 +++++++++++++++ lang/en/lang.yaml | 4 + lang/pl/lang.yaml | 4 + mcp.go | 38 ++++++++ mcp_test.go | 148 ++++++++++++++++++++++++++++++ routes.go | 21 +++-- 10 files changed, 808 insertions(+), 7 deletions(-) create mode 100644 classes/owner.go create mode 100644 classes/rules.go create mode 100644 controllers/api/auth.go create mode 100644 controllers/api/seo.go create mode 100644 mcp.go create mode 100644 mcp_test.go diff --git a/classes/owner.go b/classes/owner.go new file mode 100644 index 0000000..7e1593c --- /dev/null +++ b/classes/owner.go @@ -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 +} diff --git a/classes/rules.go b/classes/rules.go new file mode 100644 index 0000000..2b02343 --- /dev/null +++ b/classes/rules.go @@ -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 + } +} diff --git a/classes/service.go b/classes/service.go index a4af6ff..19155a4 100644 --- a/classes/service.go +++ b/classes/service.go @@ -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 { diff --git a/controllers/api/auth.go b/controllers/api/auth.go new file mode 100644 index 0000000..f22fc9c --- /dev/null +++ b/controllers/api/auth.go @@ -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) +} diff --git a/controllers/api/seo.go b/controllers/api/seo.go new file mode 100644 index 0000000..44a058c --- /dev/null +++ b/controllers/api/seo.go @@ -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 + }) +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 1b3d2c1..d38e8fb 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -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. diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 5472a4e..2e21c2e 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -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). diff --git a/mcp.go b/mcp.go new file mode 100644 index 0000000..466b3d8 --- /dev/null +++ b/mcp.go @@ -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}, + }, + }} +} diff --git a/mcp_test.go b/mcp_test.go new file mode 100644 index 0000000..5a8f4c3 --- /dev/null +++ b/mcp_test.go @@ -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) + } +} diff --git a/routes.go b/routes.go index e9e68a3..57fe9d7 100644 --- a/routes.go +++ b/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), } }