feat: sitemap settings singleton with content types and additional addresses

- one settings tile opening the golem15.sitemap.settings singleton
- Files tab (site address, file size, S1 files partial), Additional
  addresses repeater, Content types tab built from signpost at boot
- golem15_sitemap_types; a type switched off leaves the files (reason type)
- a save validates (errors.*) and dispatches a rebuild after commit
This commit is contained in:
Jakub Zych
2026-11-04 11:06:29 +01:00
parent 3359f1a196
commit 3ba70bb69f
20 changed files with 1304 additions and 337 deletions

View File

@@ -45,7 +45,7 @@ func TestFillable(t *testing.T) {
if rules := (models.Definition{}).Rules(); len(rules) != 0 {
t.Fatalf("Rules = %v", rules)
}
if got := (models.Settings{}).Fillable(); slices.Contains(got, "id") || len(got) != 9 {
if got := (models.Settings{}).Fillable(); slices.Contains(got, "id") || len(got) != 3 {
t.Fatalf("Settings.Fillable = %v", got)
}
}

View File

@@ -2,6 +2,7 @@ package models
import (
"net/url"
"strconv"
"strings"
"time"
"unicode"
@@ -9,7 +10,6 @@ import (
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// errFrontendURL is the phrase key of the frontend_url refusal.
@@ -95,28 +95,62 @@ func (s Settings) Extra() []ExtraURL { return s.ExtraURLs.Get() }
func (Settings) TableName() string { return "golem15_sitemap_settings" }
// Fillable lists only the settings columns.
// Fillable lists the columns the settings form edits.
func (Settings) Fillable() []string {
return []string{
"frontend_url", "frontend_default_locale", "updates_manifest_path",
"enable_priority_tiers", PriorityHomepage, PriorityServices,
PriorityBlog, PriorityCategories, PriorityLegal,
}
return []string{"site_url", "file_size", "extra_urls"}
}
// Rules is empty: the PHP settings form validated nothing. The one check of
// the port, on frontend_url, is in BeforeValidate.
func (Settings) Rules() map[string]string { return map[string]string{} }
// BeforeValidate is the lagoon.HasBeforeValidate hook cabana calls on a
// settings save, after the fill and before the row is written: a
// frontend_url that AllowedFrontendURL refuses is a validation error on
// frontend_url, so the save answers 422 and the row is left as it was.
func (s Settings) BeforeValidate(*gorm.DB) error {
if AllowedFrontendURL(Text(s.FrontendURL)) {
// The phrase keys of the other settings refusals.
const (
errFileSize = "golem15.sitemap::lang.errors.file_size"
errURLScheme = "golem15.sitemap::lang.errors.url_scheme"
errLastmod = "golem15.sitemap::lang.errors.lastmod"
)
// Validate answers every problem Problems finds as a validation error on
// its field; the settings controller calls it before a save, so the save
// answers 422 and the row is left as it was.
func (s Settings) Validate() error {
problems := s.Problems()
if len(problems) == 0 {
return nil
}
return &cabana.ValidationError{Details: map[string]any{"frontend_url": []string{errFrontendURL}}}
details := make(map[string]any, len(problems))
for field, keys := range problems {
details[field] = keys
}
return &cabana.ValidationError{Details: details}
}
// Problems lists the settings' refusals by field (UI-SPEC C26): a site
// address that AllowedFrontendURL refuses (site_url and the Phase 17
// frontend_url), a file size outside 1..MaxFileSize and an additional
// address that is not a path or an http(s) URL (extra_urls.<i>.url) or
// whose lastmod is not a date (extra_urls.<i>.lastmod).
func (s Settings) Problems() map[string][]string {
out := map[string][]string{}
if !AllowedFrontendURL(Text(s.SiteURL)) {
out["site_url"] = []string{errFrontendURL}
}
if !AllowedFrontendURL(Text(s.FrontendURL)) {
out["frontend_url"] = []string{errFrontendURL}
}
if s.FileSize < 1 || s.FileSize > MaxFileSize {
out["file_size"] = []string{errFileSize}
}
for i, e := range s.Extra() {
if !AllowedAddress(strings.TrimSpace(e.URL)) {
out["extra_urls."+strconv.Itoa(i)+".url"] = []string{errURLScheme}
}
if mod := strings.TrimSpace(e.LastMod); mod != "" && !ValidDate(mod) {
out["extra_urls."+strconv.Itoa(i)+".lastmod"] = []string{errLastmod}
}
}
return out
}
// AllowedFrontendURL reports whether u may be the frontend_url setting: the
@@ -180,3 +214,26 @@ func (s Settings) Priority(key string) string {
func init() {
Register(Settings{})
}
// dateLayouts are the lastmod forms an additional address may carry.
var dateLayouts = []string{time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02"}
// ParseDate reads a lastmod as the settings form or an import gives it
// (YYYY-MM-DD, "YYYY-MM-DD hh:mm:ss" or RFC 3339; UTC unless it names a
// zone); nil when empty or unreadable.
func ParseDate(s string) *time.Time {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
for _, layout := range dateLayouts {
if t, err := time.Parse(layout, s); err == nil {
t = t.UTC()
return &t
}
}
return nil
}
// ValidDate reports whether s is a lastmod ParseDate reads.
func ValidDate(s string) bool { return ParseDate(s) != nil }

View File

@@ -2,67 +2,49 @@
# Form Field Definitions
# ===================================
#
# The WinterCMS form's nginx hint and Priority Tiers section are field
# comments and README text here, and the tier fields always show (the
# trigger on enable_priority_tiers is not ported).
# Sitemap settings (UI-SPEC C26): Files, Content types (one block per
# signpost content type, added by the plugin's form extension after Files)
# and Additional addresses. A repeater row takes no date picker, so an
# additional address's lastmod is a text field read as YYYY-MM-DD.
fields:
frontend_url:
label: golem15.sitemap::lang.settings.frontend_url
comment: golem15.sitemap::lang.settings.frontend_url_comment
site_url:
label: golem15.sitemap::lang.settings.site_url
comment: golem15.sitemap::lang.settings.site_url_comment
type: text
span: left
tab: golem15.sitemap::lang.settings.tab_files
frontend_default_locale:
label: golem15.sitemap::lang.settings.frontend_default_locale
comment: golem15.sitemap::lang.settings.frontend_default_locale_comment
type: text
file_size:
label: golem15.sitemap::lang.settings.file_size
comment: golem15.sitemap::lang.settings.file_size_comment
type: number
default: 1000
span: right
tab: golem15.sitemap::lang.settings.tab_files
updates_manifest_path:
label: golem15.sitemap::lang.settings.updates_manifest_path
comment: golem15.sitemap::lang.settings.updates_manifest_path_comment
type: text
_files:
type: partial
path: sitemap_files
span: full
tab: golem15.sitemap::lang.settings.tab_files
enable_priority_tiers:
label: golem15.sitemap::lang.settings.enable_priority_tiers
comment: golem15.sitemap::lang.settings.enable_priority_tiers_comment
type: checkbox
default: true
extra_urls:
label: golem15.sitemap::lang.settings.extra_urls
comment: golem15.sitemap::lang.settings.extra_urls_comment
type: repeater
prompt: golem15.sitemap::lang.settings.extra_urls_add
titleFrom: url
span: full
priority_homepage:
label: golem15.sitemap::lang.settings.priority_homepage
comment: golem15.sitemap::lang.settings.priority_homepage_comment
type: text
default: '1.0'
span: auto
priority_services:
label: golem15.sitemap::lang.settings.priority_services
comment: golem15.sitemap::lang.settings.priority_services_comment
type: text
default: '0.8'
span: auto
priority_blog:
label: golem15.sitemap::lang.settings.priority_blog
comment: golem15.sitemap::lang.settings.priority_blog_comment
type: text
default: '0.6'
span: auto
priority_categories:
label: golem15.sitemap::lang.settings.priority_categories
comment: golem15.sitemap::lang.settings.priority_categories_comment
type: text
default: '0.4'
span: auto
priority_legal:
label: golem15.sitemap::lang.settings.priority_legal
comment: golem15.sitemap::lang.settings.priority_legal_comment
type: text
default: '0.2'
span: auto
tab: golem15.sitemap::lang.settings.tab_extra
form:
fields:
url:
label: golem15.sitemap::lang.settings.extra_url
type: text
span: left
lastmod:
label: golem15.sitemap::lang.settings.extra_url_lastmod
comment: golem15.sitemap::lang.settings.extra_url_lastmod_comment
type: text
span: right

View File

@@ -10,9 +10,6 @@ import (
"git.golem15.com/golem15/summercms/modules/lagoon"
)
// cabana calls the hook on a settings save through this interface.
var _ lagoon.HasBeforeValidate = (*models.Settings)(nil)
// TestAllowedFrontendURL pins the one definition of a frontend_url that may
// be saved, imported and applied (T-17-15): empty, or an absolute http(s)
// URL with a host and without whitespace, control characters, a double
@@ -67,31 +64,59 @@ func TestAllowedFrontendURL(t *testing.T) {
}
}
// TestSettingsBeforeValidate pins the save check of T-17-15: the hook cabana
// calls on a settings save passes a null, empty or accepted frontend_url and
// answers a validation error keyed frontend_url for any other value.
func TestSettingsBeforeValidate(t *testing.T) {
// TestSettingsValidate pins the save checks of the settings singleton
// (UI-SPEC C26, T-33-12): the check the controller runs before a save passes
// a null, empty or accepted site_url, a file size from 1 to 50 000 and
// additional addresses that are paths or http(s) URLs, and answers a
// validation error keyed by the field for any other value.
func TestSettingsValidate(t *testing.T) {
text := func(s string) *string { return &s }
for _, url := range []*string{nil, text(""), text("https://www.ex.pl/")} {
if err := (&models.Settings{FrontendURL: url}).BeforeValidate(nil); err != nil {
t.Errorf("BeforeValidate(%q) = %v, want nil", models.Text(url), err)
ok := func(s models.Settings) models.Settings {
if s.FileSize == 0 {
s.FileSize = 1000
}
return s
}
extra := func(urls ...string) lagoon.Jsonable[[]models.ExtraURL] {
rows := []models.ExtraURL{}
for _, u := range urls {
rows = append(rows, models.ExtraURL{URL: u})
}
return lagoon.Jsonable[[]models.ExtraURL]{Data: rows, Valid: true}
}
for name, s := range map[string]models.Settings{
"null site_url": ok(models.Settings{}),
"empty site_url": ok(models.Settings{SiteURL: text("")}),
"site_url": ok(models.Settings{SiteURL: text("https://www.ex.pl/")}),
"file size 1": {FileSize: 1},
"file size 50 000": {FileSize: 50000},
"extra addresses": ok(models.Settings{ExtraURLs: extra("/", "/o-nas?x=1&y=2", "https://www.ex.pl/kontakt")}),
} {
if err := s.Validate(); err != nil {
t.Errorf("%s: Validate = %v, want nil", name, err)
}
}
want := map[string]any{"frontend_url": []string{"golem15.sitemap::lang.errors.frontend_url"}}
for _, url := range []string{
"javascript:alert(document.domain)//",
`https://x"/><y a="`,
"0",
" https://www.ex.pl",
for name, c := range map[string]struct {
s models.Settings
want map[string]any
}{
"javascript site_url": {ok(models.Settings{SiteURL: text("javascript:alert(document.domain)//")}), map[string]any{"site_url": []string{"golem15.sitemap::lang.errors.frontend_url"}}},
"attribute site_url": {ok(models.Settings{SiteURL: text(`https://x"/><y a="`)}), map[string]any{"site_url": []string{"golem15.sitemap::lang.errors.frontend_url"}}},
"path site_url": {ok(models.Settings{SiteURL: text("/x")}), map[string]any{"site_url": []string{"golem15.sitemap::lang.errors.frontend_url"}}},
"file size 0": {models.Settings{}, map[string]any{"file_size": []string{"golem15.sitemap::lang.errors.file_size"}}},
"file size 50 001": {models.Settings{FileSize: 50001}, map[string]any{"file_size": []string{"golem15.sitemap::lang.errors.file_size"}}},
"javascript address": {ok(models.Settings{ExtraURLs: extra("/", "javascript:alert(1)")}), map[string]any{"extra_urls.1.url": []string{"golem15.sitemap::lang.errors.url_scheme"}}},
"protocol-relative": {ok(models.Settings{ExtraURLs: extra("//evil.test/x")}), map[string]any{"extra_urls.0.url": []string{"golem15.sitemap::lang.errors.url_scheme"}}},
"address with a space": {ok(models.Settings{ExtraURLs: extra("/a b")}), map[string]any{"extra_urls.0.url": []string{"golem15.sitemap::lang.errors.url_scheme"}}},
} {
err := (&models.Settings{FrontendURL: &url}).BeforeValidate(nil)
err := c.s.Validate()
var invalid *cabana.ValidationError
if !errors.As(err, &invalid) {
t.Errorf("BeforeValidate(%q) = %v, want a *cabana.ValidationError", url, err)
t.Errorf("%s: Validate = %v, want a *cabana.ValidationError", name, err)
continue
}
if !reflect.DeepEqual(invalid.Details, want) {
t.Errorf("BeforeValidate(%q) details = %v, want %v", url, invalid.Details, want)
if !reflect.DeepEqual(invalid.Details, c.want) {
t.Errorf("%s: details = %v, want %v", name, invalid.Details, c.want)
}
}
}

27
models/type_setting.go Normal file
View File

@@ -0,0 +1,27 @@
package models
import (
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
)
// TypeSetting is the sitemap's setting of one signpost content type on
// golem15_sitemap_types, edited on the settings singleton's Content types
// tab: whether its records are listed, the file name part of its group
// (sitemap-<slug>-<n>.xml; "" means the default) and its heading per locale
// in the HTML sitemap. A type without a row is listed under its default
// slug.
type TypeSetting struct {
TypeKey string `gorm:"column:type_key;primaryKey"`
Include bool `gorm:"column:include"`
Slug string `gorm:"column:slug"`
Titles lagoon.Jsonable[map[string]string] `gorm:"column:titles"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (TypeSetting) TableName() string { return "golem15_sitemap_types" }
func init() {
Register(TypeSetting{})
}