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..url) or // whose lastmod is not a date (extra_urls..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 }