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
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -20,6 +24,24 @@ var reservedWidgetTags = map[string]bool{
|
||||
"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}
|
||||
@@ -45,6 +67,9 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
|
||||
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
|
||||
}
|
||||
@@ -83,10 +108,84 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user