BREAKING CHANGE: /sitemap.xsl is gone (404), the definitions screen and the Phase 17 settings tile are replaced by the settings singleton, and priority, change frequency, priority tiers and the update manifest are no longer written or read. The config keys golem15.sitemap.cache_seconds and golem15.sitemap.priority_tiers are removed; site_url, lastmod_ignore and rebuild_delay_seconds are new. - sitemap:import and sitemap:import-winter write the additional addresses (and site_url, default_locale) instead of the definition - the golem15_sitemap_definitions table stays in the database, unread
183 lines
6.3 KiB
Go
183 lines
6.3 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 site address refusal (the
|
|
// Phase 17 frontend_url phrase, unchanged).
|
|
const errFrontendURL = "golem15.sitemap::lang.errors.frontend_url"
|
|
|
|
// Settings is the sitemap settings singleton (row id 1) on
|
|
// golem15_sitemap_settings (UI-SPEC C26). SiteURL is the public site
|
|
// address every <loc> starts with (empty: golem15.sitemap.site_url, then
|
|
// app.url), FileSize the addresses per file, DefaultLocale the site's
|
|
// default locale (the unprefixed one, and the target of x-default) and
|
|
// ExtraURLs the additional addresses. RebuildPendingUntil is the debounce
|
|
// time of the next queued rebuild, NumberedSize the file size the files are
|
|
// numbered for, BuiltAt the end of the last rebuild and RebuildRequestedAt
|
|
// the last rebuild asked for from the admin. The Phase 17 columns
|
|
// (frontend_url, priorities, tiers, the update manifest) stay in the table
|
|
// and are no longer read (D-32).
|
|
type Settings struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
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 settings controller runs Validate before a save.
|
|
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, 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 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 site address: the empty
|
|
// string, or an absolute http or https URL with a host and without
|
|
// whitespace, control characters, a double quote, <, > or &. The value
|
|
// starts every <loc> and hreflang link of the sitemaps, so a value outside
|
|
// this form (a javascript: URL, or text that closes the attribute it is
|
|
// written into) would reach their readers (T-17-15, T-33-12).
|
|
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 read when no row is stored.
|
|
func DefaultSettings() Settings {
|
|
return Settings{ID: 1, FileSize: DefaultFileSize}
|
|
}
|
|
|
|
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 }
|