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.
269 lines
8.3 KiB
Go
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
|
|
}
|