Files
summercms/modules/signpost/signpost.go
Jakub Zych 964f1a7f11 fix(17): CR-01 ignore an item URL pattern override that is not a path
signpost.Registry.Pattern now takes an item override only when it starts
with a single "/" and holds no surrounding whitespace or control
characters (signpost.ValidItemPattern); a javascript: URL or a
protocol-relative //host falls through to the next layer.
2026-10-12 21:25:30 +02:00

246 lines
8.5 KiB
Go

// Package signpost is the menu item type registry: it collects the typed menu
// item types plugins register through pact.HasMenuItemTypes once at boot,
// layers their URL patterns with the host's overrides and resolves items
// through the owning plugin.
package signpost
import (
"context"
"fmt"
"strings"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
)
// ConfigKey is the host configuration key of the URL pattern overrides: a
// map from menu item type key to pattern, such as
// menu_items.patterns.acme-post: /news/:slug. It is read once at boot.
const ConfigKey = "menu_items.patterns"
// Registry holds every registered menu item type and the host's pattern
// overrides. It is built once by Activate and read-only afterwards, so it is
// safe for concurrent use.
type Registry struct {
types []pact.MenuItemType
byKey map[string]int
owners map[string]string
host map[string]string
}
// Activate collects the menu item types of every plugin that implements
// pact.HasMenuItemTypes, in plugin order and then declaration order, reads the
// host pattern overrides at ConfigKey and publishes one *Registry on app.
// Consumers read it with app.Lookup[*signpost.Registry](). Every error is
// prefixed "signpost: " and fails the boot: an empty key, a key two plugins
// register (the message names both plugin IDs), a nil Resolve, a
// DefaultPattern that is empty or does not start with "/", a PatternFrom that
// names an unknown type, the type itself or a type that sets PatternFrom, and
// a host override for an unknown type or not starting with "/".
func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error {
if app == nil {
return fmt.Errorf("signpost: app is nil")
}
reg := &Registry{byKey: map[string]int{}, owners: map[string]string{}, host: map[string]string{}}
for _, p := range plugins {
src, ok := any(p).(pact.HasMenuItemTypes)
if !ok || src == nil {
continue
}
for _, t := range src.MenuItemTypes() {
if err := reg.add(p.ID(), t); err != nil {
return err
}
}
}
if err := reg.checkPatternFrom(); err != nil {
return err
}
if err := reg.loadHost(app); err != nil {
return err
}
if err := app.Publish(reg); err != nil {
return fmt.Errorf("signpost: %w", err)
}
return nil
}
func (r *Registry) add(owner string, t pact.MenuItemType) error {
if strings.TrimSpace(t.Key) == "" {
return fmt.Errorf("signpost: plugin %s registers a menu item type with an empty key", owner)
}
if first, dup := r.owners[t.Key]; dup {
return fmt.Errorf("signpost: menu item type %q is registered by both %s and %s", t.Key, first, owner)
}
if t.Resolve == nil {
return fmt.Errorf("signpost: menu item type %q of plugin %s has no Resolve", t.Key, owner)
}
if !strings.HasPrefix(t.DefaultPattern, "/") {
return fmt.Errorf("signpost: menu item type %q of plugin %s needs a DefaultPattern starting with \"/\", got %q", t.Key, owner, t.DefaultPattern)
}
t.Placeholders = append([]string(nil), t.Placeholders...)
r.owners[t.Key] = owner
r.byKey[t.Key] = len(r.types)
r.types = append(r.types, t)
return nil
}
// checkPatternFrom runs after every type is collected: a list type may name
// only another registered type that is not itself a list type.
func (r *Registry) checkPatternFrom() error {
for _, t := range r.types {
if t.PatternFrom == "" {
continue
}
if t.PatternFrom == t.Key {
return fmt.Errorf("signpost: menu item type %q names itself in PatternFrom", t.Key)
}
target, ok := r.byKey[t.PatternFrom]
if !ok {
return fmt.Errorf("signpost: menu item type %q has PatternFrom %q, which no plugin registers", t.Key, t.PatternFrom)
}
if r.types[target].PatternFrom != "" {
return fmt.Errorf("signpost: menu item type %q has PatternFrom %q, which sets PatternFrom itself (one level only)", t.Key, t.PatternFrom)
}
}
return nil
}
// loadHost reads the host pattern overrides. An absent key means none.
func (r *Registry) loadHost(app *backpack.App) error {
if app.Config == nil || !app.Config.Has(ConfigKey) {
return nil
}
patterns := map[string]string{}
if err := app.Config.LoadSection(ConfigKey, &patterns); err != nil {
return fmt.Errorf("signpost: %s must be a map of menu item type to URL pattern: %w", ConfigKey, err)
}
for key, pattern := range patterns {
if _, ok := r.byKey[key]; !ok {
return fmt.Errorf("signpost: %s overrides %q, which no plugin registers", ConfigKey, key)
}
if !strings.HasPrefix(pattern, "/") {
return fmt.Errorf("signpost: %s.%s must start with \"/\", got %q", ConfigKey, key, pattern)
}
r.host[key] = pattern
}
return nil
}
// Types returns every registered type in plugin activation order, then
// declaration order. The slice is a copy.
func (r *Registry) Types() []pact.MenuItemType {
if r == nil {
return []pact.MenuItemType{}
}
return append([]pact.MenuItemType{}, r.types...)
}
// Type returns the type registered under key.
func (r *Registry) Type(key string) (pact.MenuItemType, bool) {
if r == nil {
return pact.MenuItemType{}, false
}
i, ok := r.byKey[key]
if !ok {
return pact.MenuItemType{}, false
}
return r.types[i], true
}
// Pattern returns the URL pattern of an item of type key, in layers: the
// item's own override when it is not empty, else the host override of key,
// else, for a list type that sets PatternFrom, the host override of that
// record type, else the type's DefaultPattern. An unknown key without an item
// override answers "".
//
// The item override is taken only when it is a path (ValidItemPattern): one
// that is not, such as a javascript: URL or a protocol-relative //host, is
// ignored and the next layer applies, so stored item data cannot point a
// resolved URL at another scheme or host.
func (r *Registry) Pattern(key, itemOverride string) string {
if p := strings.TrimSpace(itemOverride); p != "" && ValidItemPattern(p) {
return p
}
if r == nil {
return ""
}
if p, ok := r.host[key]; ok {
return p
}
t, ok := r.Type(key)
if !ok {
return ""
}
if t.PatternFrom != "" {
if p, ok := r.host[t.PatternFrom]; ok {
return p
}
}
return t.DefaultPattern
}
// ValidItemPattern reports whether p may serve as an item's URL pattern
// override: it starts with a single "/" (not "//" or "/\", which browsers
// read as another host) and holds no whitespace at its ends and no control
// characters, which browsers strip before reading a URL.
func ValidItemPattern(p string) bool {
if p == "" || p[0] != '/' || strings.HasPrefix(p, "//") || strings.HasPrefix(p, `/\`) {
return false
}
if strings.TrimSpace(p) != p {
return false
}
return !strings.ContainsFunc(p, func(r rune) bool { return r < 0x20 || r == 0x7f })
}
// Resolve resolves one item of type key through the type's Resolve, handing
// it the layered pattern (Pattern(key, itemPattern)). An unknown key is an
// error.
func (r *Registry) Resolve(ctx context.Context, key, reference, itemPattern string, nesting bool, unprefixedLocale string) (*pact.ResolvedMenuItem, error) {
t, ok := r.Type(key)
if !ok {
return nil, fmt.Errorf("signpost: unknown menu item type %q", key)
}
if ctx == nil {
ctx = context.Background()
}
return t.Resolve(ctx, pact.MenuItemResolve{
Type: key,
Reference: reference,
Pattern: r.Pattern(key, itemPattern),
Nesting: nesting,
UnprefixedLocale: unprefixedLocale,
})
}
// ReferenceOptions is the one admin binding of the registry: the options of a
// reference dropdown that depends on a type dropdown. A type the registry
// does not know, or one without References, answers Applies false. Otherwise
// the reference tree is flattened depth first into options whose Depth is the
// nesting level (0 at the top). Labels are passed through as the type returns
// them.
func ReferenceOptions(ctx context.Context, r *Registry, typeKey string) (pact.DependentOptionSet, error) {
t, ok := r.Type(typeKey)
if !ok || t.References == nil {
return pact.DependentOptionSet{Applies: false, Options: []pact.Option{}}, nil
}
if ctx == nil {
ctx = context.Background()
}
refs, err := t.References(ctx)
if err != nil {
return pact.DependentOptionSet{}, fmt.Errorf("signpost: references of %q: %w", typeKey, err)
}
options := []pact.Option{}
var walk func(items []pact.MenuItemReference, depth int)
walk = func(items []pact.MenuItemReference, depth int) {
for _, item := range items {
options = append(options, pact.Option{Value: item.Value, Label: item.Label, Depth: depth})
walk(item.Items, depth+1)
}
}
walk(refs, 0)
return pact.DependentOptionSet{Applies: true, Options: options}, nil
}