- migration 202610280003: settings links_new_tab (default on),
nofollow_domains and sponsored_domains; robots llms_enabled and
llms_intro (used by the llms.txt commit)
- SEO settings Links tab: the new-tab switch and the two domain lists,
one domain per line, lowercased, refused with settings.domain_invalid
naming the line; writable through seo_update_settings
- the resolve answer of an ok page carries outbound {new_tab,
nofollow_domains, sponsored_domains}; no existing key changes
328 lines
10 KiB
Go
328 lines
10 KiB
Go
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", "links_new_tab", "nofollow_domains",
|
||
"sponsored_domains", "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,
|
||
"links_new_tab": st.LinksNewTab,
|
||
"nofollow_domains": st.NofollowDomains,
|
||
"sponsored_domains": st.SponsoredDomains,
|
||
"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 "nofollow_domains":
|
||
st.NofollowDomains, err = text()
|
||
case "sponsored_domains":
|
||
st.SponsoredDomains, 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 "links_new_tab":
|
||
b, ok := v.(bool)
|
||
if !ok {
|
||
return fmt.Errorf("golem15.seo: settings %s is not a bool", column)
|
||
}
|
||
st.LinksNewTab = 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, "", ",", ";")
|
||
if _, sent := in.Values["default_og_image"]; sent && !ValidImageURL(row.DefaultOGImage) {
|
||
problems["default_og_image"] = append(problems["default_og_image"], models.Problem{Key: errImageURL})
|
||
}
|
||
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}
|
||
}
|
||
row.NofollowDomains = models.NormalizeDomains(row.NofollowDomains)
|
||
row.SponsoredDomains = models.NormalizeDomains(row.SponsoredDomains)
|
||
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)
|
||
}
|
||
})
|
||
}
|