Files
sm-sitemap-plugin/routes.go
Jakub Zych 4a82240429 feat(17-03): full sitemap generation, settings, hooks and import commands
- generator in Definition::addItemToSet order: 50000 URL cap, lastmod from
  the record or the updates manifest (regular file, 5 MiB cap, fail safe),
  BeforeAddItem (skip) and AddItem (extra XML) festival events, tier or item
  priority, per-alternate expansion of list type children
- priority tiers from golem15.sitemap.priority_tiers, compiled at boot,
  defaults equal to the PHP getPriorityForUrl rules
- frontend_url rewrite of loc, xhtml:link href and the stylesheet href;
  GET /sitemap.xsl serves the PHP stylesheet byte for byte
- golem15_sitemap_settings singleton and Settings model
- sitemap:import (PHP ImportCommand) and sitemap:import-winter with the
  read-only Winter export SQL
- urlto and full goldens generated from PHP; TestSitemapImportSmoke
2026-10-12 17:07:39 +02:00

168 lines
5.2 KiB
Go

package sitemap
import (
"errors"
"log/slog"
"net"
"net/http"
"net/netip"
"strings"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/signpost"
"git.golem15.com/golem15/summercms/modules/surf"
"gorm.io/gorm"
)
// The response headers of the PHP routes (live responses).
const (
contentTypeXML = "application/xml"
contentTypeXSL = "text/xsl; charset=UTF-8"
cacheControl = "no-cache, private"
)
// Routes mounts GET /sitemap.xml and GET /sitemap.xsl at the application
// root, outside any throttle group and without the backend middleware:
// search engines and browsers fetch them anonymously.
func (p *Plugin) Routes(r pact.Router) error {
r.Get("/sitemap.xml", p.sitemapXML)
r.Get("/sitemap.xsl", p.sitemapXSL)
return nil
}
// sitemapXSL serves the embedded stylesheet, the PHP file byte for byte.
func (p *Plugin) sitemapXSL(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", contentTypeXSL)
w.Header().Set("Cache-Control", cacheControl)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(sitemapXSL)
}
// sitemapXML serves the definition with the fixed theme key: 404 when there
// is none, an empty application/xml body when it has no items, else the
// generated XML with the settings' frontend_url rewrite applied.
func (p *Plugin) sitemapXML(w http.ResponseWriter, r *http.Request) {
app := p.app
db, err := database(app)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
var defs []models.Definition
if err := db.WithContext(r.Context()).Where("theme = ?", models.DefinitionTheme).Order("id ASC").Limit(1).Find(&defs).Error; err != nil {
slog.Default().Error("golem15.sitemap: load definition", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if len(defs) == 0 {
slog.Default().Info("golem15.sitemap: No sitemap definition was found. Try creating one first.")
http.NotFound(w, r)
return
}
settings, err := loadSettings(db.WithContext(r.Context()))
if err != nil {
slog.Default().Error("golem15.sitemap: load settings", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
root := backendURL(app, r)
gen := &classes.Generator{Root: root, Settings: settings, Tiers: p.tiers, Events: app.Events}
if reg, ok := app.Lookup[*signpost.Registry](); ok && reg != nil {
gen.Resolver = reg
}
xml, err := gen.Generate(r.Context(), defs[0].Items())
if err != nil {
slog.Default().Error("golem15.sitemap: generate", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
xml = classes.Rewrite(xml, classes.BackendURL(root), models.Text(settings.FrontendURL))
w.Header().Set("Content-Type", contentTypeXML)
w.Header().Set("Cache-Control", cacheControl)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(xml))
}
// loadSettings reads the settings row; without one the PHP defaults apply.
func loadSettings(db *gorm.DB) (models.Settings, error) {
var rows []models.Settings
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
return models.Settings{}, err
}
if len(rows) == 0 {
return models.DefaultSettings(), nil
}
return rows[0], nil
}
// database returns the app's published *gorm.DB.
func database(app *backpack.App) (*gorm.DB, error) {
if app == nil {
return nil, errors.New("golem15.sitemap: app is not booted")
}
db, ok := app.Lookup[*gorm.DB]()
if !ok || db == nil {
return nil, errors.New("golem15.sitemap: database is not configured")
}
return db, nil
}
// backendURL is the root URL the sitemap's absolute URLs start with, PHP
// url('/') right-trimmed of slashes: app.url when it is set, else the
// request's scheme and Host. The scheme is https for a TLS connection, or
// when the immediate peer is in http.trusted_proxies and its last
// X-Forwarded-Proto value says https; a client that sends the header
// directly is ignored.
func backendURL(app *backpack.App, r *http.Request) string {
if app != nil && app.Config != nil {
if u := strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/"); u != "" {
return u
}
}
scheme := "http"
if isHTTPS(app, r) {
scheme = "https"
}
return strings.TrimRight(scheme+"://"+r.Host, "/")
}
func isHTTPS(app *backpack.App, r *http.Request) bool {
if r.TLS != nil {
return true
}
if app == nil || app.Config == nil {
return false
}
trusted := surf.TrustedProxies(app.Config)
if len(trusted) == 0 || !peerTrusted(r.RemoteAddr, trusted) {
return false
}
values := r.Header.Values("X-Forwarded-Proto")
if len(values) == 0 {
return false
}
hops := strings.Split(values[len(values)-1], ",")
return strings.EqualFold(strings.TrimSpace(hops[len(hops)-1]), "https")
}
func peerTrusted(remoteAddr string, trusted []netip.Prefix) bool {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
addr, err := netip.ParseAddr(strings.Trim(host, "[]"))
if err != nil {
return false
}
addr = addr.Unmap()
for _, p := range trusted {
if p.Contains(addr) {
return true
}
}
return false
}