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

116
controllers/api/html.go Normal file
View File

@@ -0,0 +1,116 @@
package api
import (
"net/http"
"regexp"
"strconv"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/wire"
"gorm.io/gorm"
)
// GroupPattern is the {group} constraint of the HTML sitemap group route:
// a file group slug.
const GroupPattern = `[a-z0-9][a-z0-9-]{0,63}`
// localePattern is the form a locale query value must have.
var localePattern = regexp.MustCompile(`^[a-z]{2,3}([-_][A-Za-z0-9]{2,8})*$`)
// pagePattern is the form of the page query value: 1 to 6 digits.
var pagePattern = regexp.MustCompile(`^[0-9]{1,6}$`)
// htmlRequest checks the shared parts of an HTML sitemap request and
// returns the settings and database, or answers 404 (the HTML sitemap is
// off, a malformed locale) or 500 itself.
func htmlRequest(app func() *backpack.App, w http.ResponseWriter, r *http.Request) (classes.HTMLSitemap, bool) {
w.Header().Set("X-Robots-Tag", "noindex")
w.Header().Set("Cache-Control", "no-cache, private")
a := app()
if a == nil {
http.NotFound(w, r)
return classes.HTMLSitemap{}, false
}
if loc := r.URL.Query().Get("locale"); loc != "" && !localePattern.MatchString(loc) {
http.NotFound(w, r)
return classes.HTMLSitemap{}, false
}
db, ok := a.Lookup[*gorm.DB]()
if !ok || db == nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return classes.HTMLSitemap{}, false
}
return classes.HTMLSitemap{App: a, DB: db.WithContext(r.Context())}, true
}
// HTMLOverview serves GET /_sitemap/api/v1/html?locale=: the HTML sitemap
// overview (D-34; classes.HTMLSitemap.Overview). It lists exactly the
// addresses of the XML sitemaps and answers 404 while the HTML sitemap is
// off. Every answer carries X-Robots-Tag: noindex.
func HTMLOverview(app func() *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h, ok := htmlRequest(app, w, r)
if !ok {
return
}
settings, err := classes.LoadSettings(h.DB)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if !settings.HTMLEnabled {
http.NotFound(w, r)
return
}
out, err := h.Overview(r.Context(), settings, r.URL.Query().Get("locale"))
if err != nil {
log.Error("golem15.sitemap: html overview", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
wire.WriteJSON(w, http.StatusOK, out)
}
}
// HTMLGroup serves GET /_sitemap/api/v1/html/{group}?locale=&page=: one
// page (from 1, the default) of a group (classes.HTMLSitemap.Group). An
// unknown group, a page past the last, a malformed page or the HTML
// sitemap off answer 404.
func HTMLGroup(app func() *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h, ok := htmlRequest(app, w, r)
if !ok {
return
}
page := 1
if raw := r.URL.Query().Get("page"); raw != "" {
n, err := strconv.Atoi(raw)
if !pagePattern.MatchString(raw) || err != nil || n < 1 {
http.NotFound(w, r)
return
}
page = n
}
settings, err := classes.LoadSettings(h.DB)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if !settings.HTMLEnabled {
http.NotFound(w, r)
return
}
out, found, err := h.Group(r.Context(), settings, r.URL.Query().Get("locale"), r.PathValue("group"), page)
if err != nil {
log.Error("golem15.sitemap: html group", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if !found {
http.NotFound(w, r)
return
}
wire.WriteJSON(w, http.StatusOK, out)
}
}

View File

@@ -0,0 +1,7 @@
package api
import "git.golem15.com/golem15/summercms/modules/diary"
// log is the handlers' logger: its records carry component=sitemap, like
// the rest of the plugin (log.components.sitemap).
var log = diary.For("sitemap")