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:
259
classes/issues.go
Normal file
259
classes/issues.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
)
|
||||
|
||||
// The kinds of the CMS-data issue report (D-13, D-14 CMS part). The
|
||||
// crawler's checks of the rendered pages are Phase 33.
|
||||
const (
|
||||
IssueMissingTitle = "missing_title"
|
||||
IssueMissingDescription = "missing_description"
|
||||
IssueDuplicateTitle = "duplicate_title"
|
||||
IssueDuplicateDesc = "duplicate_description"
|
||||
IssueRuleOverridesRecord = "rule_overrides_record"
|
||||
IssueRedirectChain = "redirect_chain"
|
||||
IssueCanonicalNoindex = "canonical_noindex"
|
||||
IssueCanonicalRedirected = "canonical_redirected"
|
||||
IssueNoindexBlocked = "noindex_blocked"
|
||||
)
|
||||
|
||||
// Issue is one finding of the issue report: the address, the kind, the
|
||||
// details of the finding and the MCP tool that fixes it.
|
||||
type Issue struct {
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
Tool string `json:"tool"`
|
||||
}
|
||||
|
||||
// The tools the issues point to (pier prefixes them with seo_).
|
||||
const (
|
||||
toolSetURLSEO = "seo_set_url_seo"
|
||||
toolUpdateRule = "seo_update_rule"
|
||||
toolUpdateRedirect = "seo_update_redirect"
|
||||
toolUpdateRobots = "seo_update_robots"
|
||||
)
|
||||
|
||||
// Issues builds the CMS-data issue report (D-13, D-14): missing effective
|
||||
// titles and descriptions per record and locale, effective titles and
|
||||
// descriptions shared by more than one address (records and URL rules),
|
||||
// URL rules overriding a record, redirect chains with their final
|
||||
// targets, canonicals pointing to a noindex or redirected address, and
|
||||
// noindex addresses robots.txt keeps crawlers from. Records are read
|
||||
// through their SEO entries (the generation job creates one per
|
||||
// published record and locale).
|
||||
func (s *Service) Issues(ctx context.Context) ([]Issue, error) {
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Issue
|
||||
var entries []models.Entry
|
||||
if err := db.Where("url <> ''").Order("url ASC, locale ASC").Find(&entries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rules []models.Rule
|
||||
if err := db.Order("url ASC").Find(&rules).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Missing and duplicate effective values.
|
||||
type place struct{ url, locale string }
|
||||
titles := map[string][]place{}
|
||||
descs := map[string][]place{}
|
||||
for _, e := range entries {
|
||||
if strings.TrimSpace(e.EffectiveTitle) == "" {
|
||||
out = append(out, Issue{Kind: IssueMissingTitle, URL: e.URL, Locale: e.Locale, Tool: toolSetURLSEO})
|
||||
} else {
|
||||
k := e.Locale + "|" + strings.ToLower(strings.TrimSpace(e.EffectiveTitle))
|
||||
titles[k] = append(titles[k], place{e.URL, e.Locale})
|
||||
}
|
||||
if strings.TrimSpace(e.EffectiveDescription) == "" {
|
||||
out = append(out, Issue{Kind: IssueMissingDescription, URL: e.URL, Locale: e.Locale, Tool: toolSetURLSEO})
|
||||
} else {
|
||||
k := e.Locale + "|" + strings.ToLower(strings.TrimSpace(e.EffectiveDescription))
|
||||
descs[k] = append(descs[k], place{e.URL, e.Locale})
|
||||
}
|
||||
}
|
||||
locale := s.DefaultLocale(ctx, db)
|
||||
for _, r := range rules {
|
||||
if r.Match != models.MatchExact {
|
||||
continue
|
||||
}
|
||||
if t := strings.TrimSpace(r.Title); t != "" {
|
||||
k := locale + "|" + strings.ToLower(t)
|
||||
titles[k] = append(titles[k], place{r.URL, ""})
|
||||
}
|
||||
if d := strings.TrimSpace(r.Description); d != "" {
|
||||
k := locale + "|" + strings.ToLower(d)
|
||||
descs[k] = append(descs[k], place{r.URL, ""})
|
||||
}
|
||||
}
|
||||
duplicates := func(groups map[string][]place, kind string) {
|
||||
for key, list := range groups {
|
||||
if len(list) < 2 {
|
||||
continue
|
||||
}
|
||||
_, text, _ := strings.Cut(key, "|")
|
||||
for i, p := range list {
|
||||
others := make([]string, 0, len(list)-1)
|
||||
for j, o := range list {
|
||||
if j != i && o.url != p.url {
|
||||
others = append(others, o.url)
|
||||
}
|
||||
}
|
||||
if len(others) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, Issue{Kind: kind, URL: p.url, Locale: p.locale, Tool: toolSetURLSEO, Details: map[string]any{"value": text, "also": others}})
|
||||
}
|
||||
}
|
||||
}
|
||||
duplicates(titles, IssueDuplicateTitle)
|
||||
duplicates(descs, IssueDuplicateDesc)
|
||||
|
||||
// URL rules overriding a record.
|
||||
if reg := s.Registry(); reg != nil {
|
||||
for _, r := range rules {
|
||||
if r.Match != models.MatchExact || strings.Contains(r.URL, "?") {
|
||||
continue
|
||||
}
|
||||
var fields []string
|
||||
for _, p := range models.RuleProperties {
|
||||
if strings.TrimSpace(r.Value(p.Column)) != "" {
|
||||
fields = append(fields, p.Column)
|
||||
}
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
key, match, err := reg.MatchPath(ctx, trimSlash(r.URL), s.UnprefixedLocale())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if match == nil || !match.Exact {
|
||||
continue
|
||||
}
|
||||
out = append(out, Issue{Kind: IssueRuleOverridesRecord, URL: r.URL, Locale: match.Locale, Tool: toolUpdateRule, Details: map[string]any{
|
||||
"rule_id": r.ID, "type": key, "id": match.ID, "fields": fields,
|
||||
}})
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect chains.
|
||||
redirects, err := LoadRedirects(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set := NewRedirectSet(redirects)
|
||||
site := s.SiteURL()
|
||||
for _, r := range redirects {
|
||||
if r.Status == models.Status410 {
|
||||
continue
|
||||
}
|
||||
path, query, ok := redirectProbe(r)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ch, err := set.Follow(site, path, query, MaxRedirectHops)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ch.Loop || len(ch.Hops) < 2 || ch.Hops[0].Rule.ID != r.ID {
|
||||
continue
|
||||
}
|
||||
out = append(out, Issue{Kind: IssueRedirectChain, URL: r.Source, Tool: toolUpdateRedirect, Details: map[string]any{
|
||||
"redirect_id": r.ID, "hops": len(ch.Hops), "final": displayURL(site, ch.Final), "path": displayPath(r, ch),
|
||||
}})
|
||||
}
|
||||
|
||||
// Canonicals pointing to a noindex or redirected address.
|
||||
canonical := func(u, loc, value, tool string, details map[string]any) error {
|
||||
kind, target, err := s.canonicalProblem(ctx, value)
|
||||
if err != nil || kind == "" {
|
||||
return err
|
||||
}
|
||||
if details == nil {
|
||||
details = map[string]any{}
|
||||
}
|
||||
details["canonical"] = value
|
||||
if target != "" {
|
||||
details["target"] = target
|
||||
}
|
||||
out = append(out, Issue{Kind: kind, URL: u, Locale: loc, Tool: tool, Details: details})
|
||||
return nil
|
||||
}
|
||||
for _, e := range entries {
|
||||
if err := canonical(e.URL, e.Locale, e.Canonical, toolSetURLSEO, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, r := range rules {
|
||||
if err := canonical(r.URL, "", r.Canonical, toolUpdateRule, map[string]any{"rule_id": r.ID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Noindex addresses robots.txt blocks.
|
||||
robots, err := LoadRobots(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocked, err := s.BlockedNoindex(db, robots.Content, robots.Bots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, u := range blocked {
|
||||
out = append(out, Issue{Kind: IssueNoindexBlocked, URL: u, Tool: toolUpdateRobots})
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Kind != out[j].Kind {
|
||||
return out[i].Kind < out[j].Kind
|
||||
}
|
||||
if out[i].URL != out[j].URL {
|
||||
return out[i].URL < out[j].URL
|
||||
}
|
||||
return out[i].Locale < out[j].Locale
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// canonicalProblem checks a canonical on this site (D-06): a redirect
|
||||
// answers canonical_redirected with the final address, a page served
|
||||
// noindex (in production) canonical_noindex. Other sites and empty values
|
||||
// answer no kind.
|
||||
func (s *Service) canonicalProblem(ctx context.Context, canonical string) (kind, target string, err error) {
|
||||
canonical = strings.TrimSpace(canonical)
|
||||
if canonical == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
ref := canonical
|
||||
if u, err := url.Parse(canonical); err == nil && u.IsAbs() {
|
||||
if !sameSite(s.SiteURL(), u.Host) {
|
||||
return "", "", nil
|
||||
}
|
||||
ref = u.String()
|
||||
}
|
||||
res, err := s.Resolver().Resolve(ctx, ref)
|
||||
if err != nil {
|
||||
if err == ErrBadURL {
|
||||
return "", "", nil
|
||||
}
|
||||
return "", "", err
|
||||
}
|
||||
if res.Action == ActionRedirect {
|
||||
return IssueCanonicalRedirected, res.Location, nil
|
||||
}
|
||||
if s.Staging() || res.SEO == nil || !strings.Contains(res.SEO.Robots, "noindex") {
|
||||
return "", "", nil
|
||||
}
|
||||
return IssueCanonicalNoindex, "", nil
|
||||
}
|
||||
64
classes/manage.go
Normal file
64
classes/manage.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LoadRedirect returns redirect id; ErrNoRecord when there is none.
|
||||
func LoadRedirect(db *gorm.DB, id uint) (models.Redirect, error) {
|
||||
var rows []models.Redirect
|
||||
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
|
||||
return models.Redirect{}, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return models.Redirect{}, ErrNoRecord
|
||||
}
|
||||
return rows[0], nil
|
||||
}
|
||||
|
||||
// LoadNotFound returns 404 log entry id; ErrNoRecord when there is none.
|
||||
func LoadNotFound(db *gorm.DB, id uint) (models.NotFound, error) {
|
||||
var rows []models.NotFound
|
||||
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
|
||||
return models.NotFound{}, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return models.NotFound{}, ErrNoRecord
|
||||
}
|
||||
return rows[0], nil
|
||||
}
|
||||
|
||||
// NotFoundValues are a 404 log entry's values for history.
|
||||
func NotFoundValues(n models.NotFound) map[string]any {
|
||||
return map[string]any{"url": n.URL, "hits": n.Hits, "referer": n.Referer}
|
||||
}
|
||||
|
||||
// DeleteNotFound removes a 404 log entry inside tx and records it in
|
||||
// history; the address is logged again on its next 404.
|
||||
func (s *Service) DeleteNotFound(ctx context.Context, tx *gorm.DB, n models.NotFound) error {
|
||||
if err := tx.Delete(&models.NotFound{}, n.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Record(ctx, tx, Change{
|
||||
Kind: models.SubjectNotFound,
|
||||
SubjectID: strconv.FormatUint(uint64(n.ID), 10),
|
||||
What: s.Phrase("golem15.seo::lang.history.what_notfound", map[string]string{"url": n.URL}),
|
||||
Before: NotFoundValues(n),
|
||||
})
|
||||
}
|
||||
|
||||
// RedirectOrigin is the origin of a redirect created through ctx: mcp
|
||||
// through MCP, import through an import, else manual.
|
||||
func RedirectOrigin(ctx context.Context) string {
|
||||
switch ViaOf(ctx) {
|
||||
case models.ViaMCP:
|
||||
return models.OriginMCP
|
||||
case models.ViaImport:
|
||||
return models.OriginImport
|
||||
}
|
||||
return models.OriginManual
|
||||
}
|
||||
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
|
||||
}
|
||||
308
classes/settings_write.go
Normal file
308
classes/settings_write.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user