feat: management API and an MCP tool per SEO admin operation

- /_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
This commit is contained in:
2026-10-27 12:17:36 +01:00
parent cadf6ad805
commit aa0c3bcacf
22 changed files with 2723 additions and 46 deletions

308
classes/settings_write.go Normal file
View File

@@ -0,0 +1,308 @@
package classes
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// SettingsRow returns settings row 1 inside db, creating it with the
// defaults when the migration's row is gone.
func SettingsRow(db *gorm.DB) (models.Settings, error) {
var rows []models.Settings
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
return models.Settings{}, err
}
if len(rows) > 0 {
return rows[0], nil
}
row := models.DefaultSettings()
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
return models.Settings{}, err
}
return LoadSettings(db)
}
// SettingsColumns are the settings columns the API reads and writes, in
// form order: the admin form's columns and the CSV delimiter of the
// import and export screen.
var SettingsColumns = []string{
"site_name", "twitter_site", "title_template", "home_template",
"default_description", "default_og_image", "query_params",
"noindex_paths", "search_paths", "url_host", "url_slash",
"url_lowercase", "verify_google", "verify_bing", "verify_yandex",
"verify_other", "verify_files", "csv_delimiter",
}
// SettingsValues are the settings row's values keyed by column, for
// history and the API.
func SettingsValues(st models.Settings) map[string]any {
return map[string]any{
"site_name": st.SiteName,
"twitter_site": st.TwitterSite,
"title_template": st.TitleTemplate,
"home_template": st.HomeTemplate,
"default_description": st.DefaultDescription,
"default_og_image": st.DefaultOGImage,
"query_params": plainValue(st.Params()),
"noindex_paths": st.NoindexPaths,
"search_paths": st.SearchPaths,
"url_host": st.URLHost,
"url_slash": st.URLSlash,
"url_lowercase": st.URLLowercase,
"verify_google": st.VerifyGoogle,
"verify_bing": st.VerifyBing,
"verify_yandex": st.VerifyYandex,
"verify_other": plainValue(st.OtherMeta()),
"verify_files": plainValue(st.Files()),
"csv_delimiter": st.CSVDelimiter,
}
}
// SetSettingsValue writes one column of the settings from a JSON value
// (a string, a bool, or a list of objects); an unknown column or a value
// of the wrong shape is an error.
func SetSettingsValue(st *models.Settings, column string, v any) error {
text := func() (string, error) {
switch s := v.(type) {
case string:
return s, nil
case nil:
return "", nil
}
return "", fmt.Errorf("golem15.seo: settings %s is not text", column)
}
list := func(dst any) error {
raw, err := json.Marshal(v)
if err != nil {
return err
}
if string(raw) == "null" {
raw = []byte("[]")
}
return json.Unmarshal(raw, dst)
}
var err error
switch column {
case "site_name":
st.SiteName, err = text()
case "twitter_site":
st.TwitterSite, err = text()
case "title_template":
st.TitleTemplate, err = text()
case "home_template":
st.HomeTemplate, err = text()
case "default_description":
st.DefaultDescription, err = text()
case "default_og_image":
st.DefaultOGImage, err = text()
case "noindex_paths":
st.NoindexPaths, err = text()
case "search_paths":
st.SearchPaths, err = text()
case "url_host":
st.URLHost, err = text()
case "url_slash":
st.URLSlash, err = text()
case "verify_google":
st.VerifyGoogle, err = text()
case "verify_bing":
st.VerifyBing, err = text()
case "verify_yandex":
st.VerifyYandex, err = text()
case "csv_delimiter":
st.CSVDelimiter, err = text()
case "url_lowercase":
b, ok := v.(bool)
if !ok {
return fmt.Errorf("golem15.seo: settings %s is not a bool", column)
}
st.URLLowercase = b
case "query_params":
var out []models.QueryParam
if err = list(&out); err == nil {
st.QueryParams = lagoon.Jsonable[[]models.QueryParam]{Data: out, Valid: true}
}
case "verify_other":
var out []models.VerifyMeta
if err = list(&out); err == nil {
st.VerifyOther = lagoon.Jsonable[[]models.VerifyMeta]{Data: out, Valid: true}
}
case "verify_files":
var out []models.VerifyFile
if err = list(&out); err == nil {
st.VerifyFiles = lagoon.Jsonable[[]models.VerifyFile]{Data: out, Valid: true}
}
default:
return fmt.Errorf("golem15.seo: unknown settings column %q", column)
}
return err
}
// TypeSettingWrite changes the settings of one content type: a nil field
// is left as it is.
type TypeSettingWrite struct {
Inject *bool `json:"inject,omitempty"`
TitleTemplate *string `json:"title_template,omitempty"`
DescriptionTemplate *string `json:"description_template,omitempty"`
Robots *string `json:"robots,omitempty"`
}
// SettingsWrite is one change of the SEO settings: Values are columns of
// SettingsColumns with JSON values, Types per content type key.
type SettingsWrite struct {
Values map[string]any
Types map[string]TypeSettingWrite
}
// SaveSettings writes the settings inside tx and records them in history,
// like the settings screen (D-104): templates with an unknown placeholder,
// malformed or duplicate verification files, an unknown address option or
// delimiter, robots values outside the grammar and unknown content types
// are refused with a *ProblemsError. Once the write commits, the entry
// generation job is queued so the effective-value cache follows.
func (s *Service) SaveSettings(ctx context.Context, tx *gorm.DB, in SettingsWrite) (models.Settings, error) {
row, err := SettingsRow(tx)
if err != nil {
return models.Settings{}, err
}
before := SettingsValues(row)
problems := map[string][]models.Problem{}
known := map[string]bool{}
for _, c := range SettingsColumns {
known[c] = true
}
for column, v := range in.Values {
if !known[column] {
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.field_unknown"})
continue
}
if s, ok := v.(string); ok {
v = strings.TrimSpace(s)
}
if err := SetSettingsValue(&row, column, v); err != nil {
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.value_invalid"})
}
}
oneOf := func(column, value string, allowed ...string) {
for _, a := range allowed {
if value == a {
return
}
}
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.value_invalid"})
}
oneOf("url_host", row.URLHost, models.HostKeep, models.HostWWW, models.HostBare)
oneOf("url_slash", row.URLSlash, models.SlashKeep, models.SlashAdd, models.SlashDrop)
oneOf("csv_delimiter", row.CSVDelimiter, "", ",", ";")
for field, list := range row.Problems() {
problems[field] = append(problems[field], list...)
}
types, err := LoadTypeSettings(tx)
if err != nil {
return models.Settings{}, err
}
changed := map[string]models.TypeSetting{}
reg := s.Registry()
for key, w := range in.Types {
field := "types." + key
if reg == nil {
problems[field] = append(problems[field], models.Problem{Key: "golem15.seo::lang.api.type_unknown"})
continue
}
if _, ok := reg.ContentType(key); !ok {
problems[field] = append(problems[field], models.Problem{Key: "golem15.seo::lang.api.type_unknown"})
continue
}
ts, ok := types[key]
if !ok {
ts = models.DefaultTypeSetting(key)
}
if w.Inject != nil {
ts.Inject = *w.Inject
}
if w.TitleTemplate != nil {
ts.TitleTemplate = strings.TrimSpace(*w.TitleTemplate)
problems[field+".title_template"] = append(problems[field+".title_template"], models.TemplateProblems(ts.TitleTemplate)...)
}
if w.DescriptionTemplate != nil {
ts.DescriptionTemplate = strings.TrimSpace(*w.DescriptionTemplate)
problems[field+".description_template"] = append(problems[field+".description_template"], models.TemplateProblems(ts.DescriptionTemplate)...)
}
if w.Robots != nil {
normal, err := NormalizeRobots(*w.Robots)
if err != nil {
problems[field+".robots"] = append(problems[field+".robots"], models.Problem{Key: "golem15.seo::lang.robots.invalid"})
}
ts.Robots = normal
}
changed[key] = ts
}
for field, list := range problems {
if len(list) == 0 {
delete(problems, field)
}
}
if len(problems) > 0 {
return models.Settings{}, &ProblemsError{Problems: problems}
}
now := time.Now().UTC()
row.UpdatedAt = &now
if err := tx.Save(&row).Error; err != nil {
return models.Settings{}, err
}
if err := s.Record(ctx, tx, Change{
Kind: models.SubjectSettings,
SubjectID: "1",
What: s.Phrase("golem15.seo::lang.history.what_settings", nil),
Before: before,
After: SettingsValues(row),
}); err != nil {
return models.Settings{}, err
}
for key, ts := range changed {
old, ok := types[key]
if !ok {
old = models.DefaultTypeSetting(key)
}
if err := s.SaveTypeSetting(ctx, tx, ts); err != nil {
return models.Settings{}, err
}
label := key
if typ, ok := reg.ContentType(key); ok && typ.Label != "" {
label = s.Phrase(typ.Label, nil)
}
if err := s.Record(ctx, tx, Change{
Kind: models.SubjectSettings,
SubjectID: "type:" + key,
What: s.Phrase("golem15.seo::lang.history.what_settings", nil) + " – " + label,
Before: TypeSettingValues(old),
After: TypeSettingValues(ts),
}); err != nil {
return models.Settings{}, err
}
}
s.queueGenerateAfterCommit(ctx, tx)
return row, nil
}
// queueGenerateAfterCommit queues the entry generation job once tx
// commits; a queue error is logged.
func (s *Service) queueGenerateAfterCommit(ctx context.Context, tx *gorm.DB) {
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
db, err := s.GormDB()
if err != nil {
return
}
if _, err := s.EnqueueGenerate(ctx, db.WithContext(ctx), ""); err != nil {
log.Warn("golem15.seo: queue entry generation", "error", err)
}
})
}