- 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
240 lines
8.2 KiB
Go
240 lines
8.2 KiB
Go
package models
|
|
|
|
import (
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
)
|
|
|
|
// errFrontendURL is the phrase key of the frontend_url refusal.
|
|
const errFrontendURL = "golem15.sitemap::lang.errors.frontend_url"
|
|
|
|
// The settings keys of the five priority tiers, in the order PHP checked
|
|
// them. A tier rule names one of them.
|
|
const (
|
|
PriorityHomepage = "priority_homepage"
|
|
PriorityServices = "priority_services"
|
|
PriorityBlog = "priority_blog"
|
|
PriorityCategories = "priority_categories"
|
|
PriorityLegal = "priority_legal"
|
|
)
|
|
|
|
// PriorityDefaults are the PHP defaults of the priority settings.
|
|
var PriorityDefaults = map[string]string{
|
|
PriorityHomepage: "1.0",
|
|
PriorityServices: "0.8",
|
|
PriorityBlog: "0.6",
|
|
PriorityCategories: "0.4",
|
|
PriorityLegal: "0.2",
|
|
}
|
|
|
|
// Settings is the sitemap settings singleton (row id 1) on
|
|
// golem15_sitemap_settings, the PHP Golem15\Sitemap\Models\Settings that
|
|
// WinterCMS kept in system_settings under golem15_sitemap_settings.
|
|
// FrontendURL replaces the backend URL in the generated XML;
|
|
// FrontendDefaultLocale is the locale whose URLs carry no prefix;
|
|
// UpdatesManifestPath names a JSON file of URL path to last-modified date;
|
|
// EnablePriorityTiers turns the tier priorities on, and the priority fields
|
|
// hold each tier's value as text. A null priority reads as its PHP default.
|
|
// The PHP settings form validated nothing; the port checks FrontendURL in
|
|
// BeforeValidate.
|
|
type Settings struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
FrontendURL *string `gorm:"column:frontend_url"`
|
|
FrontendDefaultLocale *string `gorm:"column:frontend_default_locale"`
|
|
UpdatesManifestPath *string `gorm:"column:updates_manifest_path"`
|
|
EnablePriorityTiers bool `gorm:"column:enable_priority_tiers"`
|
|
PriorityHomepage *string `gorm:"column:priority_homepage"`
|
|
PriorityServices *string `gorm:"column:priority_services"`
|
|
PriorityBlog *string `gorm:"column:priority_blog"`
|
|
PriorityCategories *string `gorm:"column:priority_categories"`
|
|
PriorityLegal *string `gorm:"column:priority_legal"`
|
|
|
|
SiteURL *string `gorm:"column:site_url"`
|
|
FileSize int `gorm:"column:file_size"`
|
|
DefaultLocale *string `gorm:"column:default_locale"`
|
|
ExtraURLs lagoon.Jsonable[[]ExtraURL] `gorm:"column:extra_urls"`
|
|
RebuildPendingUntil *time.Time `gorm:"column:rebuild_pending_until"`
|
|
NumberedSize *int `gorm:"column:numbered_size"`
|
|
BuiltAt *time.Time `gorm:"column:built_at"`
|
|
RebuildRequestedAt *time.Time `gorm:"column:rebuild_requested_at"`
|
|
}
|
|
|
|
// DefaultFileSize is the number of addresses per sitemap file when the
|
|
// setting is not stored (D-30); MaxFileSize is the protocol's limit.
|
|
const (
|
|
DefaultFileSize = 1000
|
|
MaxFileSize = 50000
|
|
)
|
|
|
|
// ExtraURL is one additional address of the settings: URL is a path (or an
|
|
// absolute http(s) URL on the site), LastMod its optional last change as
|
|
// the admin's date picker sends it.
|
|
type ExtraURL struct {
|
|
URL string `json:"url"`
|
|
LastMod string `json:"lastmod"`
|
|
}
|
|
|
|
// EffectiveFileSize is the stored file size, or DefaultFileSize when it is
|
|
// outside 1..MaxFileSize.
|
|
func (s Settings) EffectiveFileSize() int {
|
|
if s.FileSize < 1 || s.FileSize > MaxFileSize {
|
|
return DefaultFileSize
|
|
}
|
|
return s.FileSize
|
|
}
|
|
|
|
// Extra returns the additional addresses.
|
|
func (s Settings) Extra() []ExtraURL { return s.ExtraURLs.Get() }
|
|
|
|
func (Settings) TableName() string { return "golem15_sitemap_settings" }
|
|
|
|
// Fillable lists the columns the settings form edits.
|
|
func (Settings) Fillable() []string {
|
|
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{} }
|
|
|
|
// 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
|
|
}
|
|
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
|
|
// empty string, or an absolute http or https URL with a host and without
|
|
// whitespace, control characters, a double quote, <, > or &. The PHP plugin
|
|
// stored any text. The value is written into every loc, every hreflang link
|
|
// and the stylesheet instruction of the sitemap, and sitemap.xsl renders
|
|
// each loc as a link on the public origin, so a value outside this form
|
|
// (a javascript: URL, or text that closes the attribute it is written into)
|
|
// would reach the readers of /sitemap.xml (T-17-15).
|
|
func AllowedFrontendURL(u string) bool {
|
|
if u == "" {
|
|
return true
|
|
}
|
|
if !utf8.ValidString(u) {
|
|
return false
|
|
}
|
|
if strings.ContainsFunc(u, func(r rune) bool {
|
|
return unicode.IsControl(r) || unicode.IsSpace(r) || r == '"' || r == '<' || r == '>' || r == '&'
|
|
}) {
|
|
return false
|
|
}
|
|
parsed, err := url.Parse(u)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return false
|
|
}
|
|
return parsed.Opaque == "" && parsed.Hostname() != ""
|
|
}
|
|
|
|
// DefaultSettings is the settings PHP read when nothing was saved: tiers on
|
|
// with the default priorities.
|
|
func DefaultSettings() Settings {
|
|
return Settings{ID: 1, EnablePriorityTiers: true, FileSize: DefaultFileSize}
|
|
}
|
|
|
|
// Priority returns the value of a priority setting: the stored text, or the
|
|
// PHP default when it is null.
|
|
func (s Settings) Priority(key string) string {
|
|
var p *string
|
|
switch key {
|
|
case PriorityHomepage:
|
|
p = s.PriorityHomepage
|
|
case PriorityServices:
|
|
p = s.PriorityServices
|
|
case PriorityBlog:
|
|
p = s.PriorityBlog
|
|
case PriorityCategories:
|
|
p = s.PriorityCategories
|
|
case PriorityLegal:
|
|
p = s.PriorityLegal
|
|
}
|
|
if p == nil {
|
|
return PriorityDefaults[key]
|
|
}
|
|
return *p
|
|
}
|
|
|
|
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 }
|