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) } }) }