package classes import ( "context" "encoding/json" "fmt" "strconv" "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", "breadcrumb_home", "publisher_type", "publisher_name", "publisher_logo", "publisher_url", "publisher_email", "publisher_phone", "publisher_same_as", "local_enabled", "local_type", "local_price_range", "local_street", "local_postal_code", "local_city", "local_region", "local_country", "local_latitude", "local_longitude", "local_hours", "page_form", "page_segment", "page_title_suffix", "page_description_suffix", "crawl_schedule", "crawl_max_pages", "crawl_delay", "crawl_keep", "generic_anchors", "autolink_max", } // settingsText maps the plain text settings columns to their fields. func settingsText(st *models.Settings) map[string]*string { return map[string]*string{ "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, "noindex_paths": &st.NoindexPaths, "search_paths": &st.SearchPaths, "url_host": &st.URLHost, "url_slash": &st.URLSlash, "verify_google": &st.VerifyGoogle, "verify_bing": &st.VerifyBing, "verify_yandex": &st.VerifyYandex, "csv_delimiter": &st.CSVDelimiter, "nofollow_domains": &st.NofollowDomains, "sponsored_domains": &st.SponsoredDomains, "breadcrumb_home": &st.BreadcrumbHome, "publisher_type": &st.PublisherType, "publisher_name": &st.PublisherName, "publisher_logo": &st.PublisherLogo, "publisher_url": &st.PublisherURL, "publisher_email": &st.PublisherEmail, "publisher_phone": &st.PublisherPhone, "publisher_same_as": &st.PublisherSameAs, "local_type": &st.LocalType, "local_price_range": &st.LocalPriceRange, "local_street": &st.LocalStreet, "local_postal_code": &st.LocalPostalCode, "local_city": &st.LocalCity, "local_region": &st.LocalRegion, "local_country": &st.LocalCountry, "local_latitude": &st.LocalLatitude, "local_longitude": &st.LocalLongitude, "page_form": &st.PageForm, "page_segment": &st.PageSegment, "page_title_suffix": &st.PageTitleSuffix, "page_description_suffix": &st.PageDescriptionSuffix, "crawl_schedule": &st.CrawlSchedule, "generic_anchors": &st.GenericAnchors, } } // settingsInts maps the number settings columns to their fields. func settingsInts(st *models.Settings) map[string]*int { return map[string]*int{ "crawl_max_pages": &st.CrawlMaxPages, "crawl_delay": &st.CrawlDelay, "crawl_keep": &st.CrawlKeep, "autolink_max": &st.AutolinkMax, } } // 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, "breadcrumb_home": st.BreadcrumbHome, "publisher_type": st.PublisherType, "publisher_name": st.PublisherName, "publisher_logo": st.PublisherLogo, "publisher_url": st.PublisherURL, "publisher_email": st.PublisherEmail, "publisher_phone": st.PublisherPhone, "publisher_same_as": st.PublisherSameAs, "local_enabled": st.LocalEnabled, "local_type": st.LocalType, "local_price_range": st.LocalPriceRange, "local_street": st.LocalStreet, "local_postal_code": st.LocalPostalCode, "local_city": st.LocalCity, "local_region": st.LocalRegion, "local_country": st.LocalCountry, "local_latitude": st.LocalLatitude, "local_longitude": st.LocalLongitude, "local_hours": plainValue(st.Hours()), "page_form": st.PageForm, "page_segment": st.PageSegment, "page_title_suffix": st.PageTitleSuffix, "page_description_suffix": st.PageDescriptionSuffix, "crawl_schedule": st.CrawlSchedule, "crawl_max_pages": st.CrawlMaxPages, "crawl_delay": st.CrawlDelay, "crawl_keep": st.CrawlKeep, "generic_anchors": st.GenericAnchors, "autolink_max": st.AutolinkMax, } } // 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) } if field, ok := settingsText(st)[column]; ok { var err error *field, err = text() return err } if field, ok := settingsInts(st)[column]; ok { switch n := v.(type) { case float64: if n != float64(int(n)) { return fmt.Errorf("golem15.seo: settings %s is not a whole number", column) } *field = int(n) case int: *field = n case string: i, err := strconv.Atoi(strings.TrimSpace(n)) if err != nil { return fmt.Errorf("golem15.seo: settings %s is not a number", column) } *field = i default: return fmt.Errorf("golem15.seo: settings %s is not a number", column) } return nil } var err error switch column { 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", "local_enabled": b, ok := v.(bool) if !ok { return fmt.Errorf("golem15.seo: settings %s is not a bool", column) } if column == "links_new_tab" { st.LinksNewTab = b } else { st.LocalEnabled = b } case "local_hours": var out []models.OpeningHours if err = list(&out); err == nil { st.LocalHours = lagoon.Jsonable[[]models.OpeningHours]{Data: out, Valid: true} } 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 _, column := range []string{"default_og_image", "publisher_logo"} { value := row.DefaultOGImage if column == "publisher_logo" { value = row.PublisherLogo } if _, sent := in.Values[column]; sent && !ValidImageURL(value) { problems[column] = append(problems[column], models.Problem{Key: errImageURL}) } } for field, list := range row.Problems() { problems[field] = append(problems[field], list...) } for field, list := range row.AuditProblems() { if _, sent := in.Values[field]; sent { 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) } }) }