- 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
168 lines
5.2 KiB
Go
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
|
|
}
|