- 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
138 lines
4.0 KiB
Go
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
|
|
}
|