- Pagination tab: query or path form, per-locale segment, title and
description suffixes with %page% and %pages%
- ?page=1, /<segment>/1 and the other form answer 301 to the configured
address in one hop with normalisation and strict URLs; a page value
that is not a number answers not_found with pagination.invalid
- page 2 and later: self canonical with the page, index,follow, the
suffixes (%pages% left for the layer), paged hreflang alternates
- GET /site answers pagination {form, param, segments} and home_crumb
388 lines
14 KiB
Go
388 lines
14 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", "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",
|
||
}
|
||
|
||
// 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,
|
||
}
|
||
}
|
||
|
||
// 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,
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
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...)
|
||
}
|
||
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)
|
||
}
|
||
})
|
||
}
|