Files
sm-sitemap-plugin/classes/generator.go
Jakub Zych c42bc265c8 fix(17): CR-01 refuse non-http sitemap URLs on save, import and output
A url item stored as javascript://%0aalert(1)%0a//x passed Url::to and
reached <loc>, which sitemap.xsl renders as a link on the site origin.

- The definitions form refuses, per row, a url that is not a path or an
  http(s) URL (classes.AllowedItemURL) and a urlPattern that is not a
  path (signpost.ValidItemPattern), keyed data.<i>.url / .urlPattern.
- Generator.addItem drops a loc or alternate that is not http(s).
- sitemap:import-winter drops such url items and clears such patterns
  with a report line; sitemap:import refuses such an entry.
2026-10-12 21:28:28 +02:00

269 lines
8.3 KiB
Go

// Package classes generates the sitemap: it walks the definition items,
// resolves registry items through the framework's menu item type registry
// and writes the PHP-identical XML.
package classes
import (
"context"
"time"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/festival"
"git.golem15.com/golem15/summercms/modules/pact"
)
// MaxURLs is the sitemap protocol's URL limit (PHP Definition::MAX_URLS):
// the 50000th URL is written, the 50001st is not.
const MaxURLs = 50000
// lastmodLayout is PHP date('c') in UTC: "+00:00", never "Z".
const lastmodLayout = "2006-01-02T15:04:05-07:00"
// Resolver resolves a registry item; *signpost.Registry implements it.
type Resolver interface {
Resolve(ctx context.Context, key, reference, itemPattern string, nesting bool, unprefixedLocale string) (*pact.ResolvedMenuItem, error)
}
// Generator writes the sitemap of a definition, following PHP
// Definition::generateSitemap and addItemToSet.
//
// Root is the backend root URL (scheme and host without a trailing slash)
// that Url::to builds absolute URLs from. Resolver resolves registry items;
// nil skips them. Settings are the sitemap settings (tiers, priorities,
// frontend default locale, manifest path), Tiers the compiled tier rules,
// and Events the bus BeforeAddItem and AddItem are fired on (nil fires
// nothing). Manifest, when set, is used instead of reading the settings'
// updates_manifest_path.
type Generator struct {
Root string
Resolver Resolver
Settings models.Settings
Tiers Tiers
Events *festival.Bus
Manifest Manifest
manifestLoaded bool
entries []URLEntry
count int
}
// itemInfo is PHP's $itemInfo: the URL (set marks it present), the record's
// modification time and the hreflang alternates.
type itemInfo struct {
url string
set bool
mtime *time.Time
alternates []Alternate
}
// Generate returns the sitemap XML of items, or "" when there are none (PHP
// generateSitemap). A url item is added with its URL. A registry item is
// resolved with its URL pattern, its nesting and the settings' frontend
// default locale as the unprefixed locale; its URL is added when it has one,
// then its sub-items: when the item has no URL of its own (a list type),
// each sub-item is added once per alternate, with that alternate's URL as
// loc and every alternate as hreflang, as the PHP list types returned them;
// otherwise each sub-item is added once with its own URL. A type the
// registry does not know, or a Resolve that finds nothing, adds nothing.
//
// Generate does not apply the frontend_url rewrite; the route does
// (Rewrite).
func (g *Generator) Generate(ctx context.Context, items []models.DefinitionItem) (string, error) {
if len(items) == 0 {
return "", nil
}
if ctx == nil {
ctx = context.Background()
}
g.entries, g.count = nil, 0
for i := range items {
item := &items[i]
if item.Type == models.ItemTypeURL {
if err := g.addItem(ctx, item, itemInfo{}); err != nil {
return "", err
}
continue
}
resolved, err := g.resolve(ctx, item)
if err != nil {
return "", err
}
if resolved == nil {
continue
}
if resolved.URL != "" {
if err := g.addItem(ctx, item, infoOf(*resolved)); err != nil {
return "", err
}
if err := g.addChildren(ctx, item, resolved.Items); err != nil {
return "", err
}
continue
}
if err := g.addPerLocale(ctx, item, resolved.Items); err != nil {
return "", err
}
}
return Serialize(g.Stylesheet(), g.entries), nil
}
// Stylesheet is the stylesheet href PHP built:
// Url::buildUrl(Url::current(), ['path' => 'sitemap.xsl']).
func (g *Generator) Stylesheet() string {
return replacePath(URLTo(g.Root, "/sitemap.xml"), "sitemap.xsl")
}
func (g *Generator) resolve(ctx context.Context, item *models.DefinitionItem) (*pact.ResolvedMenuItem, error) {
if g.Resolver == nil || item.Type == "" {
return nil, nil
}
if reg, ok := g.Resolver.(interface {
Type(string) (pact.MenuItemType, bool)
}); ok {
if _, known := reg.Type(item.Type); !known {
return nil, nil
}
}
return g.Resolver.Resolve(ctx, item.Type, models.Text(item.Reference), models.Text(item.URLPattern), item.Nesting, models.Text(g.Settings.FrontendDefaultLocale))
}
// addChildren adds every child with a URL, then its own children.
func (g *Generator) addChildren(ctx context.Context, item *models.DefinitionItem, children []pact.ResolvedMenuItem) error {
for _, child := range children {
if child.URL != "" {
if err := g.addItem(ctx, item, infoOf(child)); err != nil {
return err
}
}
if err := g.addChildren(ctx, item, child.Items); err != nil {
return err
}
}
return nil
}
// addPerLocale adds the children of a list type: each once per alternate
// (once with its URL when it has no alternates), then its own children.
func (g *Generator) addPerLocale(ctx context.Context, item *models.DefinitionItem, children []pact.ResolvedMenuItem) error {
for _, child := range children {
info := infoOf(child)
if len(child.Alternates) == 0 {
if child.URL != "" {
if err := g.addItem(ctx, item, info); err != nil {
return err
}
}
if err := g.addChildren(ctx, item, child.Items); err != nil {
return err
}
continue
}
for _, alt := range child.Alternates {
perLocale := info
perLocale.url = alt.URL
if perLocale.url != "" {
if err := g.addItem(ctx, item, perLocale); err != nil {
return err
}
}
if err := g.addChildren(ctx, item, child.Items); err != nil {
return err
}
}
}
return nil
}
func infoOf(r pact.ResolvedMenuItem) itemInfo {
info := itemInfo{url: r.URL, set: true, mtime: r.MTime}
for _, a := range r.Alternates {
info.alternates = append(info.alternates, Alternate{Hreflang: a.Locale, Href: a.URL})
}
return info
}
func (g *Generator) manifest() Manifest {
if !g.manifestLoaded {
if g.Manifest == nil {
g.Manifest = LoadManifest(models.Text(g.Settings.UpdatesManifestPath))
}
g.manifestLoaded = true
}
return g.Manifest
}
// addItem is PHP Definition::addItemToSet, in its order: the URL cap, a
// missing URL filled through Url::to, lastmod from the record or the
// manifest, BeforeAddItem, an empty URL dropped and the URL passed through
// Url::to again, the count, the tier or item priority, the elements and
// AddItem. Unlike PHP, a URL or alternate that does not come out as an
// http or https URL is dropped.
func (g *Generator) addItem(ctx context.Context, item *models.DefinitionItem, info itemInfo) error {
if g.count >= MaxURLs {
return nil
}
if !info.set {
info.url = URLTo(g.Root, models.Text(item.URL))
}
var lastmod time.Time
hasLastmod := false
if info.mtime != nil {
lastmod, hasLastmod = *info.mtime, true
} else if t, ok := g.manifest().Lastmod(info.url); ok {
lastmod, hasLastmod = t, true
}
lastmodText := ""
if hasLastmod && lastmod.Unix() != 0 {
lastmodText = lastmod.UTC().Format(lastmodLayout)
}
if g.Events != nil {
before := &BeforeAddItem{Item: *item, Info: ItemInfo{URL: info.url, MTime: info.mtime, Lastmod: lastmodText, Alternates: append([]Alternate(nil), info.alternates...)}}
skipped, err := g.Events.UntilHandled(ctx, before)
if err != nil {
return err
}
if skipped {
return nil
}
}
if info.url == "" || info.url == "0" {
return nil
}
loc := URLTo(g.Root, info.url)
if !httpURL(loc) {
// Only http and https URLs are written: a javascript: or other
// scheme that an import, a registry type or a pattern let through
// would be a clickable link in sitemap.xsl.
return nil
}
g.count++
priority, ok := g.Tiers.Priority(loc, g.Settings)
if !ok {
priority = models.Text(item.Priority)
}
entry := URLEntry{
Loc: loc,
Lastmod: lastmodText,
Changefreq: models.Text(item.Changefreq),
Priority: priority,
}
if len(info.alternates) > 1 {
for _, a := range info.alternates {
href := URLTo(g.Root, a.Href)
if !httpURL(href) {
continue
}
entry.Alternates = append(entry.Alternates, Alternate{Hreflang: a.Hreflang, Href: href})
}
}
if g.Events != nil {
added := &AddItem{Item: *item, Info: ItemInfo{URL: loc, MTime: info.mtime, Lastmod: lastmodText, Alternates: append([]Alternate(nil), entry.Alternates...)}}
if err := g.Events.Fire(ctx, added); err != nil {
return err
}
entry.Extra = added.Extra
}
g.entries = append(g.entries, entry)
return nil
}