Files
summercms/modules/cabana/extension.go
Jakub Zych 8b1cb244de feat(10.1-01): serve controller JS/CSS and run registered toolbar actions
- boardwalk exports ContentType and SetSecurityHeaders
- pact.AdminClientAssets files are read and hashed at boot and served by exact
  key under {prefix}/assets/{vendor}/{plugin}/ with nosniff, CSP, CORP,
  no-cache and an ETag; a miss falls through to the SPA
- list and form schemas carry assets URLs with a ?v= hash
- toolbar.buttons resolves create, delete and registered actions after decode;
  toolbarActions is permission-filtered per admin
- POST .../toolbar/{action} behind requireAjax and action permissions
2026-09-28 23:41:17 +02:00

227 lines
7.8 KiB
Go

package cabana
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io/fs"
"path"
"regexp"
"strings"
"git.golem15.com/golem15/summercms/modules/boardwalk"
"git.golem15.com/golem15/summercms/modules/pact"
)
// widgetTagPattern is a valid custom-element name restricted to lowercase
// ASCII: a letter, then at least one hyphenated segment.
var widgetTagPattern = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)+$`)
// reservedWidgetTags are the hyphenated names the HTML specification reserves;
// customElements.define refuses them.
var reservedWidgetTags = map[string]bool{
"annotation-xml": true, "color-profile": true, "font-face": true, "font-face-src": true,
"font-face-uri": true, "font-face-format": true, "font-face-name": true, "missing-glyph": true,
}
// assetSegment is one segment of the plugin ID in an asset URL.
var assetSegment = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// pluginAsset is one declared controller JS or CSS file, read from the
// plugin's embedded tree and hashed at boot. The asset route serves only
// these exact keys (D-13, D-15, D-16).
type pluginAsset struct {
// key is vendor/plugin/<path after assets/>, the URL tail under
// {prefix}/assets/.
key string
body []byte
contentType string
// etag is the quoted hex sha256 of body; version is its first 12 hex
// characters, used as the ?v= cache buster.
etag string
version string
}
// builtinToolbarActions are the toolbar actions the framework implements
// itself (D-14); a controller may not register an action with these names.
var builtinToolbarActions = map[string]bool{"create": true, "delete": true}
// widgetTagPrefix is the custom-element prefix a plugin's widgets must use:
// the plugin ID lowercased with dots and underscores turned into hyphens,
// plus a trailing hyphen (acme.conform -> "acme-conform-"). It keeps two
// plugins from defining the same element.
func widgetTagPrefix(pluginID string) string {
return strings.NewReplacer(".", "-", "_", "-").Replace(strings.ToLower(pluginID)) + "-"
}
// compileExtension validates a controller's runtime admin extension points
// after its list and form are compiled and its writable fields are bound: the
// registered actions and every `type: widget` field. Every failure stops boot.
func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error {
if cc == nil || cc.Controller == nil {
return nil
}
id := cc.Controller.ID()
actions, err := compileActions(cc.Controller)
if err != nil {
return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err)
}
cc.Actions = actions
if err := compileClientAssets(pluginID, cc, fsys); err != nil {
return err
}
if cc.Form == nil {
return nil
}
file := cc.Form.fieldsPath
if file == "" {
file = "fields.yaml"
}
fields := map[string]FormField{}
for _, field := range cc.Form.Fields {
fields[field.Name] = field
}
writable := map[string]bool{}
for _, field := range cc.Writable {
writable[field.Name] = true
}
prefix := widgetTagPrefix(pluginID)
for i := range cc.Form.Fields {
field := &cc.Form.Fields[i]
if field.Type != "widget" {
continue
}
if err := checkWidgetTag(field.Widget, prefix); err != nil {
return bootErr(pluginID, id, file, fmt.Errorf("field %s: %w", field.Name, err))
}
action, ok := actions[field.Action]
if !ok {
return bootErr(pluginID, id, file, fmt.Errorf("field %s: action %s is not registered by the controller (pact.HasAdminActions)", field.Name, field.Action))
}
for _, key := range field.Fill {
target, exists := fields[key]
if !exists {
return bootErr(pluginID, id, file, fmt.Errorf("field %s: fill %s is not a field of this form", field.Name, key))
}
if !scalarFormField(target.Type) || !writable[key] {
return bootErr(pluginID, id, file, fmt.Errorf("field %s: fill %s is not a writable scalar field", field.Name, key))
}
}
field.ActionLabel = action.Label
if len(cc.scripts) == 0 {
return bootErr(pluginID, id, file, fmt.Errorf("field %s: a widget needs the controller to declare its JS through pact.AdminClientAssets", field.Name))
}
}
return nil
}
// compileClientAssets reads and hashes the files a controller declares
// through pact.AdminClientAssets. Each path must be clean, live under
// assets/, carry a JS (.js, .mjs) or CSS (.css) extension and exist in the
// plugin's embedded tree; there is no disk override.
func compileClientAssets(pluginID string, cc *CompiledController, fsys fs.FS) error {
src, ok := cc.Controller.(pact.AdminClientAssets)
if !ok || src == nil {
return nil
}
id := cc.Controller.ID()
vendor, plugin, found := strings.Cut(pluginID, ".")
if !found || !assetSegment.MatchString(vendor) || !assetSegment.MatchString(plugin) {
return fmt.Errorf("cabana: admin controller %s/%s: plugin ID must be vendor.plugin to serve admin assets", pluginID, id)
}
read := func(files []string, exts ...string) ([]*pluginAsset, error) {
out := make([]*pluginAsset, 0, len(files))
seen := map[string]bool{}
for _, name := range files {
if err := checkAssetPath(name, exts); err != nil {
return nil, bootErr(pluginID, id, name, err)
}
if seen[name] {
return nil, bootErr(pluginID, id, name, fmt.Errorf("asset declared twice"))
}
seen[name] = true
body, err := fs.ReadFile(fsys, name)
if err != nil {
return nil, bootErr(pluginID, id, name, fmt.Errorf("asset is not in the plugin's embedded files: %w", err))
}
sum := sha256.Sum256(body)
digest := hex.EncodeToString(sum[:])
out = append(out, &pluginAsset{
key: vendor + "/" + plugin + "/" + strings.TrimPrefix(name, "assets/"),
body: body,
contentType: boardwalk.ContentType(name),
etag: `"` + digest + `"`,
version: digest[:12],
})
}
return out, nil
}
scripts, err := read(src.AdminJS(), ".js", ".mjs")
if err != nil {
return err
}
styles, err := read(src.AdminCSS(), ".css")
if err != nil {
return err
}
cc.scripts, cc.styles = scripts, styles
return nil
}
func checkAssetPath(name string, exts []string) error {
if name != path.Clean(name) || !strings.HasPrefix(name, "assets/") || len(name) == len("assets/") {
return fmt.Errorf("asset path must be a clean path under assets/")
}
for _, segment := range strings.Split(name, "/") {
if segment == ".." || segment == "" {
return fmt.Errorf("asset path must be a clean path under assets/")
}
}
ext := strings.ToLower(path.Ext(name))
for _, want := range exts {
if ext == want {
return nil
}
}
return fmt.Errorf("asset must end in %s", strings.Join(exts, " or "))
}
func checkWidgetTag(tag, prefix string) error {
if !widgetTagPattern.MatchString(tag) {
return fmt.Errorf("widget %q is not a valid custom-element name (lowercase, with a hyphen)", tag)
}
if reservedWidgetTags[tag] {
return fmt.Errorf("widget %q is a reserved element name", tag)
}
if !strings.HasPrefix(tag, prefix) {
return fmt.Errorf("widget %q must start with the plugin prefix %q", tag, prefix)
}
return nil
}
// compileActions collects a controller's registered actions into the single
// action namespace (assumption-delta decision: create and delete are reserved).
func compileActions(ctl pact.AdminController) (map[string]pact.AdminAction, error) {
out := map[string]pact.AdminAction{}
src, ok := ctl.(pact.HasAdminActions)
if !ok || src == nil {
return out, nil
}
for _, action := range src.AdminActions() {
if !identifier(action.Name) {
return nil, fmt.Errorf("action name %q is not an identifier", action.Name)
}
if builtinToolbarActions[action.Name] {
return nil, fmt.Errorf("action %s uses a reserved built-in name (create, delete)", action.Name)
}
if _, dup := out[action.Name]; dup {
return nil, fmt.Errorf("duplicate action %s", action.Name)
}
if action.Run == nil {
return nil, fmt.Errorf("action %s has no Run function", action.Name)
}
out[action.Name] = action
}
return out, nil
}