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/, 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 bulkActions, err := compileBulkActions(cc.Controller) if err != nil { return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err) } cc.BulkActions = bulkActions recordActions, err := compileRecordActions(cc.Controller) if err != nil { return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err) } cc.RecordActions = recordActions if err := compileClientAssets(pluginID, cc, fsys); err != nil { return err } if err := compilePartials(pluginID, cc, fsys); err != nil { return err } if cc.Form == nil { return nil } // Every recordActions name of config_form.yaml must be a record action // the controller registers, with a label (D-10). formFile := cc.Form.configPath if formFile == "" { formFile = "config_form.yaml" } // Record actions are offered on the preview screen only (D-10, D-11). if len(cc.Form.recordActions) > 0 && cc.Form.preview == nil { return bootErr(pluginID, id, formFile, fmt.Errorf("recordActions needs a preview block (record actions are offered on the preview screen)")) } for _, name := range cc.Form.recordActions { action, ok := recordActions[name] if !ok { return bootErr(pluginID, id, formFile, fmt.Errorf("recordActions: unsupported action %s (want a record action the controller registers)", name)) } if strings.TrimSpace(action.Label) == "" { return bootErr(pluginID, id, formFile, fmt.Errorf("recordActions: action %s needs a label", name)) } } file := cc.Form.fieldsPath if file == "" { file = "fields.yaml" } fields := map[string]FormField{} for _, field := range cc.Form.Fields { fields[field.Name] = field } if err := checkVirtualFields(cc, fields); err != nil { return bootErr(pluginID, id, file, err) } if err := checkPresets(cc.Form.Fields); err != nil { return bootErr(pluginID, id, file, err) } 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 } // virtualFieldTypes are the field types a controller may list through // pact.FormVirtualFields: the value is one scalar. var virtualFieldTypes = map[string]bool{ "password": true, "text": true, "textarea": true, "number": true, "checkbox": true, "switch": true, "dropdown": true, } // checkVirtualFields checks the controller's pact.FormVirtualFields list // against the form: every `type: password` field must be listed (its value is // never a model column), and every listed name must be a field of the form // with a scalar type. func checkVirtualFields(cc *CompiledController, fields map[string]FormField) error { for _, field := range cc.Form.Fields { if field.Type == "password" && !cc.virtual[field.Name] { return fmt.Errorf("field %s: type password needs the controller to list it in FormVirtualFields", field.Name) } } src, ok := cc.Controller.(pact.FormVirtualFields) if !ok || src == nil { return nil } seen := map[string]bool{} for _, name := range src.FormVirtualFields() { if seen[name] { return fmt.Errorf("FormVirtualFields lists field %s twice", name) } seen[name] = true field, exists := fields[name] if !exists { return fmt.Errorf("FormVirtualFields: field %s is not a field of this form", name) } if !virtualFieldTypes[field.Type] { return fmt.Errorf("FormVirtualFields: field %s has type %s (want password, text, textarea, number, checkbox, switch or dropdown)", name, field.Type) } } return nil } // compilePartials reads and parses every partial the controller declares: // config_list.yaml headerPartial, config_form.yaml preview.headerPartial and // each `type: partial` field's path, all resolving to {ConfigDir}/_{name}.htm. // The preview header partial is a form partial: the partial route renders it // with a record id, loaded through the form scope. A missing or unparsable template, or a // controller without pact.AdminPartialData, fails boot (D-11). func compilePartials(pluginID string, cc *CompiledController, fsys fs.FS) error { id := cc.Controller.ID() var names []string formNames := map[string]bool{} if cc.List != nil && cc.List.HeaderPartial != "" { names = append(names, cc.List.HeaderPartial) } if cc.Form != nil { if cc.Form.preview != nil && cc.Form.preview.HeaderPartial != "" { names = append(names, cc.Form.preview.HeaderPartial) formNames[cc.Form.preview.HeaderPartial] = true } for _, field := range cc.Form.Fields { if field.Type == "partial" { names = append(names, field.Path) formNames[field.Path] = true } } } if len(names) == 0 { return nil } dir := strings.Trim(path.Clean(cc.Controller.ConfigDir()), "/") if dir == "." || strings.HasPrefix(dir, "..") { return bootErr(pluginID, id, cc.Controller.ConfigDir(), fmt.Errorf("config directory escapes the plugin")) } partials := map[string]*compiledPartial{} for _, name := range names { if _, done := partials[name]; done { continue } file := path.Join(dir, "_"+name+".htm") if provider, ok := cc.Controller.(pact.AdminPartialData); !ok || provider == nil { return bootErr(pluginID, id, file, fmt.Errorf("partial %s needs the controller to implement pact.AdminPartialData", name)) } src, err := readAsset(fsys, file) if err != nil { return bootErr(pluginID, id, file, fmt.Errorf("partial %s: template is not in the plugin's embedded files: %w", name, err)) } compiled, err := parsePartial(name, src) if err != nil { return bootErr(pluginID, id, file, fmt.Errorf("partial %s: %w", name, err)) } partials[name] = compiled } cc.partials, cc.formPartials = partials, formNames 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 } // compileBulkActions collects a controller's registered bulk actions. They // have their own namespace next to the toolbar and widget actions: a name is // unique among the bulk actions, and create and delete stay reserved. func compileBulkActions(ctl pact.AdminController) (map[string]pact.AdminBulkAction, error) { out := map[string]pact.AdminBulkAction{} src, ok := ctl.(pact.HasAdminBulkActions) if !ok || src == nil { return out, nil } for _, action := range src.AdminBulkActions() { if !identifier(action.Name) { return nil, fmt.Errorf("bulk action name %q is not an identifier", action.Name) } if builtinToolbarActions[action.Name] { return nil, fmt.Errorf("bulk action %s uses a reserved built-in name (create, delete)", action.Name) } if _, dup := out[action.Name]; dup { return nil, fmt.Errorf("duplicate bulk action %s", action.Name) } if action.Run == nil { return nil, fmt.Errorf("bulk action %s has no Run function", action.Name) } out[action.Name] = action } return out, nil } // compileRecordActions collects a controller's registered record actions into // their own namespace; create and delete stay reserved. func compileRecordActions(ctl pact.AdminController) (map[string]pact.AdminRecordAction, error) { out := map[string]pact.AdminRecordAction{} src, ok := ctl.(pact.HasAdminRecordActions) if !ok || src == nil { return out, nil } for _, action := range src.AdminRecordActions() { if !identifier(action.Name) { return nil, fmt.Errorf("record action name %q is not an identifier", action.Name) } if builtinToolbarActions[action.Name] { return nil, fmt.Errorf("record action %s uses a reserved built-in name (create, delete)", action.Name) } if _, dup := out[action.Name]; dup { return nil, fmt.Errorf("duplicate record action %s", action.Name) } if action.Run == nil { return nil, fmt.Errorf("record action %s has no Run function", action.Name) } out[action.Name] = action } return out, nil }