Files
sm-seo-plugin/classes/settings_write.go
Jakub Zych aa0c3bcacf 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
2026-10-27 12:17:36 +01:00

309 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
})
}