Files
sm-seo-plugin/classes/robots_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

112 lines
3.4 KiB
Go

package classes
import (
"context"
"errors"
"strconv"
"time"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// RobotsRow returns robots row 1 inside db, creating it when the
// migration's row is gone (first or create, safe against a concurrent
// first request).
func RobotsRow(db *gorm.DB) (models.Robots, error) {
var rows []models.Robots
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
return models.Robots{}, err
}
if len(rows) > 0 {
return rows[0], nil
}
row := models.Robots{ID: 1, Bots: models.BotSwitches{}}
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
return models.Robots{}, err
}
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
return models.Robots{}, err
}
if len(rows) == 0 {
return models.Robots{}, errors.New("golem15.seo: robots row is missing")
}
return rows[0], nil
}
// RobotsValues are the robots row's values for history: the editor's text
// and every listed bot's switch.
func RobotsValues(r models.Robots) map[string]any {
bots := map[string]any{}
for _, b := range Bots {
bots[b.Token] = r.Bots.Allowed(b.Token)
}
return map[string]any{"content": r.Content, "bots": bots}
}
// RobotsWrite is one change of robots.txt: the editor's text when Content
// is set, and the switches of the bots listed in Bots (by token).
type RobotsWrite struct {
Content *string
Bots map[string]bool
}
// SaveRobots writes robots.txt inside tx and records it in history
// (D-22, D-25). A text over MaxRobotsBytes and a token that is not a
// listed AI bot are refused with a *ProblemsError. It returns the stored
// row and the noindex addresses Googlebot may no longer fetch under it
// (the robots.blocks_noindex warning).
func (s *Service) SaveRobots(ctx context.Context, tx *gorm.DB, in RobotsWrite) (models.Robots, []string, error) {
row, err := RobotsRow(tx)
if err != nil {
return models.Robots{}, nil, err
}
problems := map[string][]models.Problem{}
if in.Content != nil && len(*in.Content) > MaxRobotsBytes {
problems["content"] = append(problems["content"], models.Problem{Key: "golem15.seo::lang.robots.too_large"})
}
for token := range in.Bots {
if _, ok := BotByToken(token); !ok {
problems["bots."+token] = append(problems["bots."+token], models.Problem{Key: "golem15.seo::lang.api.bot_unknown"})
}
}
if len(problems) > 0 {
return models.Robots{}, nil, &ProblemsError{Problems: problems}
}
before := RobotsValues(row)
if in.Content != nil {
row.Content = *in.Content
}
bots := models.BotSwitches{}
for token, on := range row.Bots {
bots[token] = on
}
for token, on := range in.Bots {
b, _ := BotByToken(token)
bots[b.Token] = on
}
row.Bots = bots
now := time.Now().UTC()
row.UpdatedAt = &now
if err := tx.Model(&models.Robots{}).Where("id = ?", row.ID).Updates(map[string]any{
"content": row.Content, "bots": row.Bots, "updated_at": now,
}).Error; err != nil {
return models.Robots{}, nil, err
}
if err := s.Record(ctx, tx, Change{
Kind: models.SubjectRobots,
SubjectID: strconv.FormatUint(uint64(row.ID), 10),
What: s.Phrase("golem15.seo::lang.history.what_robots", nil),
Before: before,
After: RobotsValues(row),
}); err != nil {
return models.Robots{}, nil, err
}
blocked, err := s.BlockedNoindex(tx, row.Content, row.Bots)
if err != nil {
return models.Robots{}, nil, err
}
return row, blocked, nil
}