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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
27
models/type_setting.go
Normal 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{})
|
||||
}
|
||||
Reference in New Issue
Block a user