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:
111
classes/robots_write.go
Normal file
111
classes/robots_write.go
Normal file
@@ -0,0 +1,111 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user