Files
sm-sitemap-plugin/routes.go
Jakub Zych fe39e26ce0 feat(17-03): sitemap plugin tracer, /sitemap.xml byte-identical to PHP
- golem15.sitemap plugin: golem15_sitemap_definitions migration, Definition
  model (Fillable theme and data) with tolerant PHP-shaped item decoding and
  PHP toArray item encoding
- Url::to and buildUrl port, libxml-format serializer, generator for url and
  registry items
- GET /sitemap.xml serves the definition with theme default (404 without one,
  empty body without items)
- tests/php/gen-goldens.php and the tracer golden; TestSitemapGolden and the
  Postgres TestSitemapTracer
2026-10-12 16:53:09 +02:00

138 lines
4.0 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"
cacheControl = "no-cache, private"
)
// Routes mounts GET /sitemap.xml at the application root, outside any
// throttle group and without the backend middleware: search engines fetch it
// anonymously.
func (p *Plugin) Routes(r pact.Router) error {
r.Get("/sitemap.xml", p.sitemapXML)
return nil
}
// sitemapXML serves the definition with the fixed theme key (D-11): 404
// when there is none, an empty application/xml body when it has no items,
// else the generated XML.
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
}
gen := &classes.Generator{Root: backendURL(app, r)}
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
}
w.Header().Set("Content-Type", contentTypeXML)
w.Header().Set("Cache-Control", cacheControl)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(xml))
}
// 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
}