feat: HTML sitemap groups API

- GET /_sitemap/api/v1/html?locale= answers the overview: the listed
  addresses of the XML sitemaps by group, in the order pages, offers,
  services, categories (as a tree), posts (newest first), tags, authors,
  other content types, each with its title in the locale, total, the
  first html_page_size links and more
- GET /_sitemap/api/v1/html/{group}?locale=&page= answers one page of a
  group; past the last page, an unknown group or the HTML sitemap off
  answer 404; every answer is noindex; per-IP bucket sitemap-html
- the HTML sitemap tab: html_enabled, the address per locale (html_paths)
  and html_page_size 50 to 500, with errors.html_path and errors.page_size
- contract.Listing.Settings answers HTMLEnabled, HTMLPaths, HTMLPageSize
  and KeyFile
This commit is contained in:
Jakub Zych
2026-11-04 12:32:47 +01:00
parent e02d0b173f
commit 8f4ff2a97d
12 changed files with 981 additions and 67 deletions

View File

@@ -92,7 +92,7 @@ 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", "images_enabled", "news_enabled", "news_publication", "news_sources", "indexnow_enabled"}
return []string{"site_url", "file_size", "extra_urls", "images_enabled", "news_enabled", "news_publication", "news_sources", "indexnow_enabled", "html_enabled", "html_page_size"}
}
// Rules is empty: the settings controller runs Validate before a save.
@@ -105,6 +105,8 @@ const (
errLastmod = "golem15.sitemap::lang.errors.lastmod"
errNewsName = "golem15.sitemap::lang.errors.news_publication"
errNewsSrc = "golem15.sitemap::lang.errors.news_source"
errHTMLPath = "golem15.sitemap::lang.errors.html_path"
errPageSize = "golem15.sitemap::lang.errors.page_size"
)
// MaxNewsPublication is the longest publication name.
@@ -128,7 +130,9 @@ func (s Settings) Validate() error {
// 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).
// whose lastmod is not a date (extra_urls.<i>.lastmod), a publication name
// over 255 characters, a malformed News source, an HTML sitemap address
// that ValidHTMLPath refuses (html_path) and a page size outside 50..500.
func (s Settings) Problems() map[string][]string {
out := map[string][]string{}
if !AllowedFrontendURL(Text(s.SiteURL)) {
@@ -154,9 +158,42 @@ func (s Settings) Problems() map[string][]string {
break
}
}
for _, p := range s.HTMLPaths.Get() {
if p = strings.TrimSpace(p); p != "" && !ValidHTMLPath(p) {
out["html_path"] = []string{errHTMLPath}
break
}
}
if s.HTMLPageSize < MinHTMLPageSize || s.HTMLPageSize > MaxHTMLPageSize {
out["html_page_size"] = []string{errPageSize}
}
return out
}
// HTMLPathProblem is the refusal of an HTML sitemap address (errors.html_path).
const HTMLPathProblem = errHTMLPath
// ValidHTMLPath reports whether p may be the HTML sitemap's address: a
// path that starts with a single /, at most 255 characters of letters,
// digits and - _ . ~ / (no query, fragment, "//" or ".." segment), and not
// the site root.
func ValidHTMLPath(p string) bool {
if len(p) < 2 || len(p) > 255 || p[0] != '/' || strings.Contains(p, "//") {
return false
}
for _, seg := range strings.Split(p[1:], "/") {
if seg == "." || seg == ".." {
return false
}
}
for _, r := range p {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("-_.~/", r)) {
return false
}
}
return true
}
// The prefixes of the news_sources values (P33-15): type:<type key> and
// category:<type key>:<id>.
const (

View File

@@ -127,3 +127,21 @@ fields:
action: hide
field: indexnow_enabled
condition: unchecked
# The address per locale (html_path, an mltext field) is added after
# this switch by the plugin's form extension, stored in html_paths.
html_enabled:
label: golem15.sitemap::lang.settings.html_enabled
comment: golem15.sitemap::lang.settings.html_enabled_comment
type: switch
default: true
span: full
tab: golem15.sitemap::lang.settings.tab_html
html_page_size:
label: golem15.sitemap::lang.settings.html_page_size
comment: golem15.sitemap::lang.settings.html_page_size_comment
type: number
default: 150
span: right
tab: golem15.sitemap::lang.settings.tab_html

View File

@@ -75,6 +75,9 @@ func TestSettingsValidate(t *testing.T) {
if s.FileSize == 0 {
s.FileSize = 1000
}
if s.HTMLPageSize == 0 {
s.HTMLPageSize = models.DefaultHTMLPageSize
}
return s
}
extra := func(urls ...string) lagoon.Jsonable[[]models.ExtraURL] {
@@ -88,8 +91,8 @@ func TestSettingsValidate(t *testing.T) {
"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},
"file size 1": ok(models.Settings{FileSize: 1}),
"file size 50 000": ok(models.Settings{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 {
@@ -103,8 +106,8 @@ func TestSettingsValidate(t *testing.T) {
"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"}}},
"file size 0": {models.Settings{HTMLPageSize: 150}, map[string]any{"file_size": []string{"golem15.sitemap::lang.errors.file_size"}}},
"file size 50 001": {models.Settings{FileSize: 50001, HTMLPageSize: 150}, 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"}}},