package cabana import ( "context" "errors" "fmt" "path" "reflect" "sort" "strings" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "git.golem15.com/golem15/summercms/modules/phrasebook" "github.com/goccy/go-yaml/ast" "github.com/goccy/go-yaml/parser" "gorm.io/gorm" ) // defaultTabKey is the name a form extension uses for the tab of the fields // without a tab: key (D-91 P-04). const defaultTabKey = "default" // extensionFieldTypes are the built-in field types a form extension may add: // a value its Save stores, a widget or a partial. The others bind to the // owner's model (fileupload, relation, relation-manager, repeater, money, // taglist, datepicker, permissioneditor, richeditor, password) and are // refused; a plugin field type is always accepted. var extensionFieldTypes = map[string]bool{ "text": true, "textarea": true, "number": true, "checkbox": true, "switch": true, "dropdown": true, "markdown": true, "mltext": true, "mltextarea": true, "mlmarkdown": true, "widget": true, "partial": true, } // compiledFormExtension is one plugin's contribution to one controller's // form (pact.AdminActionExtension.Form, D-91) after the boot checks. type compiledFormExtension struct { pluginID string def pact.AdminFormExtension // fields are the names of the fields the extension adds, in compiled // order; own holds them by name. fields []string own map[string]FormField // tabs are the tab keys the extension declares. tabs []string // actions are the widget actions of its widget fields keyed by name; // partials its parsed partial templates keyed by name. actions map[string]pact.AdminAction partials map[string]*compiledPartial } // valueField reports whether the extension's field name carries a value // (anything but a widget or a partial). func (ext *compiledFormExtension) valueField(name string) bool { field, ok := ext.own[name] return ok && field.Type != "widget" && field.Type != "partial" } // formContribution is one extension waiting to be compiled onto a target. type formContribution struct { plugin party.Plugin def pact.AdminFormExtension } // extensionDoc is a parsed form extension fields document: the new tabs and // the fields with their placement keys, in document order. type extensionDoc struct { tabs []extensionTab fields []extensionField } // extensionTab is one entry of a fields document's tabs: list. type extensionTab struct { name, before, after, zone string } // extensionField is one compiled field of a fields document with its // before:, after: and zone: keys. type extensionField struct { field FormField before, after, zone string } // placementKeys are the keys a form extension's field takes next to the // fields.yaml keys; they are taken out before the field is compiled. var placementKeys = map[string]bool{"before": true, "after": true, "zone": true} // compileFormExtensions merges the form fields and tabs plugins add through // pact.AdminActionExtension.Form (D-91) into the compiled forms of reg, in // plugin slice (activation) order. It must run after every controller is // compiled. An extension's fields document is compiled like a fields.yaml // (every type, option and plugin field type check applies), then its tabs // and fields are placed at their anchors. Every failure stops boot and names // the plugin, the controller and the offending name. func compileFormExtensions(reg *Registry, plugins []party.Plugin) error { if reg == nil { return fmt.Errorf("cabana: registry is nil") } targets := map[string][]formContribution{} requires := map[string]map[string]bool{} var order []string for _, plugin := range plugins { if plugin == nil { continue } src, ok := plugin.(pact.HasAdminActionExtensions) if !ok || src == nil { continue } needs := map[string]bool{} for _, id := range plugin.Requires() { needs[id] = true } requires[plugin.ID()] = needs for _, ext := range src.AdminActionExtensions() { if ext.Form == nil { continue } cc, ok := reg.byID[ext.Controller] if !ok || cc == nil { return fmt.Errorf("cabana: plugin %s extends the form of unknown admin controller %q", plugin.ID(), ext.Controller) } if cc.Form == nil { return fmt.Errorf("cabana: plugin %s extends the form of admin controller %s, which has no form (config_form.yaml)", plugin.ID(), ext.Controller) } if _, seen := targets[ext.Controller]; !seen { order = append(order, ext.Controller) } targets[ext.Controller] = append(targets[ext.Controller], formContribution{plugin: plugin, def: *ext.Form}) } } for _, id := range order { if err := applyFormExtensions(reg, reg.byID[id], targets[id], requires); err != nil { return err } } return nil } // extErr is a form extension boot error naming the plugin and the controller. func extErr(pluginID, controller, format string, args ...any) error { return fmt.Errorf("cabana: plugin %s: controller %q: "+format, append([]any{pluginID, controller}, args...)...) } // applyFormExtensions compiles the contributions of one target controller. func applyFormExtensions(reg *Registry, cc *CompiledController, contributions []formContribution, requires map[string]map[string]bool) error { controller := cc.Controller.ID() docs := make([]extensionDoc, len(contributions)) layout := false for i, c := range contributions { doc, err := loadExtensionDoc(reg, c, controller) if err != nil { return err } docs[i] = doc if len(doc.fields) > 0 || len(doc.tabs) > 0 || len(c.def.Hide) > 0 { layout = true } } form := newFormLayout(cc.Form.Fields, requires) for i, c := range contributions { ext := &compiledFormExtension{pluginID: c.plugin.ID(), def: c.def, own: map[string]FormField{}} if err := compileExtensionKinds(reg, cc, c.plugin, ext, &docs[i], controller); err != nil { return err } if err := form.hide(ext, controller); err != nil { return err } if err := form.addTabs(ext, docs[i].tabs, controller); err != nil { return err } if err := form.addFields(ext, docs[i].fields, controller); err != nil { return err } cc.formExtensions = append(cc.formExtensions, ext) if cc.extensionFields == nil { cc.extensionFields = map[string]*compiledFormExtension{} } for _, name := range ext.fields { cc.extensionFields[name] = ext } } if !layout { return nil } // A new slice: the owner's compiled list may be shared. cc.Form.Fields = form.flatten() for _, ext := range cc.formExtensions { for _, name := range ext.fields { if err := checkFieldReferences(cc.Form.Fields, ext.own[name]); err != nil { return extErr(ext.pluginID, controller, "%w", err) } } } dropHiddenFields(cc, form.hidden) return nil } // loadExtensionDoc reads and parses one extension's fields document. func loadExtensionDoc(reg *Registry, c formContribution, controller string) (extensionDoc, error) { pluginID := c.plugin.ID() if strings.TrimSpace(c.def.Fields) != "" && len(c.def.FieldsYAML) > 0 { return extensionDoc{}, extErr(pluginID, controller, "form extension sets both Fields and FieldsYAML") } raw := c.def.FieldsYAML source := "FieldsYAML" if file := strings.TrimSpace(c.def.Fields); file != "" { assets, ok := c.plugin.(pact.AdminAssets) if !ok || assets == nil || assets.AdminFS() == nil { return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: the plugin has no AdminFS", file) } name, err := assetPath(pluginID, file) if err != nil { return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: %w", file, err) } if raw, err = readAsset(assets.AdminFS(), name); err != nil { return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: %w", file, err) } source = name } if len(raw) == 0 { return extensionDoc{}, nil } doc, err := parseExtensionDoc(raw, reg.fieldTypes) if err != nil { return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: %w", source, err) } return doc, nil } // parseExtensionDoc compiles a form extension fields document: a mapping // with fields: (a fields.yaml mapping whose entries may add before:, after: // and zone:) and tabs: (a list of name/before/after/zone mappings). func parseExtensionDoc(raw []byte, types *fieldTypeRegistry) (extensionDoc, error) { file, err := parser.ParseBytes(raw, 0) if err != nil { return extensionDoc{}, normalizeYAMLError(err) } var entries []*ast.MappingValueNode for i, doc := range file.Docs { if doc == nil { continue } if i > 0 { return extensionDoc{}, fmt.Errorf("want one YAML document") } switch body := unwrapNode(doc.Body).(type) { case nil, *ast.NullNode: case *ast.MappingNode: entries = body.Values case *ast.MappingValueNode: entries = []*ast.MappingValueNode{body} default: return extensionDoc{}, fmt.Errorf("the document must be a mapping with fields: and tabs:") } } var out extensionDoc seen := map[string]bool{} for _, entry := range entries { key, err := nodeString(unwrapNode(entry.Key)) if err != nil { return extensionDoc{}, err } if seen[key] { return extensionDoc{}, fmt.Errorf("duplicate key %s", key) } seen[key] = true switch key { case "fields": if out.fields, err = decodeExtensionFields(entry.Value, types); err != nil { return extensionDoc{}, err } case "tabs": if out.tabs, err = decodeExtensionTabs(entry.Value); err != nil { return extensionDoc{}, err } default: return extensionDoc{}, fmt.Errorf("unknown key %s (want fields or tabs)", key) } } return out, nil } // decodeExtensionFields takes the placement keys out of every field entry // and compiles the rest through decodeFieldMapping. func decodeExtensionFields(node ast.Node, types *fieldTypeRegistry) ([]extensionField, error) { node = unwrapNode(node) placement := map[string]map[string]string{} if mapping, ok := node.(*ast.MappingNode); ok { for _, entry := range mapping.Values { name, err := nodeString(unwrapNode(entry.Key)) if err != nil { continue // decodeFieldMapping reports the name } body, ok := unwrapNode(entry.Value).(*ast.MappingNode) if !ok { continue } keys := map[string]string{} kept := make([]*ast.MappingValueNode, 0, len(body.Values)) for _, item := range body.Values { key, err := nodeString(unwrapNode(item.Key)) if err != nil || !placementKeys[key] { kept = append(kept, item) continue } value, err := nodeString(unwrapNode(item.Value)) if err != nil { return nil, fmt.Errorf("field %s: %s: %w", name, key, err) } keys[key] = strings.TrimSpace(value) } body.Values = kept placement[name] = keys } } compiled, err := decodeFieldMapping(node, nil, types) if err != nil { return nil, err } out := make([]extensionField, 0, len(compiled)) for _, field := range compiled { keys := placement[field.Name] out = append(out, extensionField{field: field, before: keys["before"], after: keys["after"], zone: keys["zone"]}) } return out, nil } // decodeExtensionTabs decodes the tabs: list. func decodeExtensionTabs(node ast.Node) ([]extensionTab, error) { node = unwrapNode(node) if _, ok := node.(*ast.NullNode); ok || node == nil { return nil, nil } list, ok := node.(*ast.SequenceNode) if !ok { return nil, fmt.Errorf("tabs must be a list of mappings with name, before, after and zone") } out := make([]extensionTab, 0, len(list.Values)) for i, item := range list.Values { mapping, ok := unwrapNode(item).(*ast.MappingNode) if !ok { return nil, fmt.Errorf("tabs[%d] must be a mapping with name, before, after and zone", i) } var tab extensionTab for _, entry := range mapping.Values { key, err := nodeString(unwrapNode(entry.Key)) if err != nil { return nil, fmt.Errorf("tabs[%d]: %w", i, err) } value, err := nodeString(unwrapNode(entry.Value)) if err != nil { return nil, fmt.Errorf("tabs[%d]: %s: %w", i, key, err) } value = strings.TrimSpace(value) switch key { case "name": tab.name = value case "before": tab.before = value case "after": tab.after = value case "zone": tab.zone = value default: return nil, fmt.Errorf("tabs[%d]: unknown key %s (want name, before, after or zone)", i, key) } } if tab.name == "" { return nil, fmt.Errorf("tabs[%d]: name is required", i) } out = append(out, tab) } return out, nil } // checkZone applies D-101: only the tab zone is rendered for now. func checkZone(zone string) error { switch zone { case "", pact.AdminFormZoneTabs: return nil case pact.AdminFormZoneOutside, pact.AdminFormZoneSecondary: return fmt.Errorf("zone %q is not supported yet (only %q)", zone, pact.AdminFormZoneTabs) default: return fmt.Errorf("unknown zone %q (want %q)", zone, pact.AdminFormZoneTabs) } } // compileExtensionKinds runs the per-field checks of one extension's fields // document before placement: the field types an extension may add, its // widget actions and their element source, its partials, and the Save hook // its value fields need. func compileExtensionKinds(reg *Registry, cc *CompiledController, plugin party.Plugin, ext *compiledFormExtension, doc *extensionDoc, controller string) error { pluginID := ext.pluginID own := map[string]FormField{} for _, item := range doc.fields { own[item.field.Name] = item.field } if err := compileExtensionActions(cc, ext, controller); err != nil { return err } values := false var partials []string for i := range doc.fields { field := &doc.fields[i].field if field.fieldType == nil && !extensionFieldTypes[field.Type] { if field.Type == "fileupload" { return extErr(pluginID, controller, "field %s: type fileupload is not supported in a form extension: an extension owns no attachments; store files through a plugin field type with Upload", field.Name) } return extErr(pluginID, controller, "field %s: type %s is not supported in a form extension", field.Name, field.Type) } if field.optionsMethod != "" { return extErr(pluginID, controller, "field %s: a form extension's dropdown lists its options (no options method)", field.Name) } if field.DependsOn != "" { return extErr(pluginID, controller, "field %s: dependsOn is not supported in a form extension", field.Name) } switch field.Type { case "widget": if err := checkWidgetTag(field.Widget, widgetTagPrefix(pluginID)); err != nil { return extErr(pluginID, controller, "field %s: %w", field.Name, err) } action, ok := ext.actions[field.Action] if !ok { return extErr(pluginID, controller, "field %s: action %s is not one of the extension's Actions", field.Name, field.Action) } for _, key := range field.Fill { target, ok := own[key] if !ok || target.Type == "widget" || target.Type == "partial" { return extErr(pluginID, controller, "field %s: fill %s is not a value field of this extension", field.Name, key) } } if !extensionElementSource(reg, plugin) { return extErr(pluginID, controller, "field %s: a widget needs its element script: register a field type with Scripts or implement pact.AdminClientAssets on the plugin", field.Name) } field.ActionLabel = action.Label case "partial": partials = append(partials, field.Path) default: values = true } } if values && ext.def.Save == nil { return extErr(pluginID, controller, "form extension has value fields but no Save") } if err := compileExtensionAssets(reg, cc, plugin, controller); err != nil { return err } return compileExtensionPartials(cc, plugin, ext, partials, controller) } // compileExtensionActions checks the extension's widget actions: identifier // names, no reserved name, a Run, and no name the owner or an earlier // extension of the controller registers. func compileExtensionActions(cc *CompiledController, ext *compiledFormExtension, controller string) error { if len(ext.def.Actions) == 0 { return nil } ext.actions = make(map[string]pact.AdminAction, len(ext.def.Actions)) for _, action := range ext.def.Actions { switch { case !identifier(action.Name): return extErr(ext.pluginID, controller, "widget action %q: the name is not an identifier", action.Name) case builtinToolbarActions[action.Name]: return extErr(ext.pluginID, controller, "widget action %s: the name is reserved (create, delete)", action.Name) case action.Run == nil: return extErr(ext.pluginID, controller, "widget action %s: it has no Run function", action.Name) } if _, dup := ext.actions[action.Name]; dup { return extErr(ext.pluginID, controller, "widget action %s: declared twice", action.Name) } if _, taken := cc.Actions[action.Name]; taken { return extErr(ext.pluginID, controller, "widget action %s: the owner already registers an action with that name", action.Name) } for _, other := range cc.formExtensions { if _, taken := other.actions[action.Name]; taken { return extErr(ext.pluginID, controller, "widget action %s: plugin %s already adds an action with that name", action.Name, other.pluginID) } } ext.actions[action.Name] = action } return nil } // extensionElementSource reports whether the contributing plugin loads a // script that can define its widget element on the target's form: a field // type script (loaded on every admin page) or the plugin's own // pact.AdminClientAssets JS (added to the target's assets). func extensionElementSource(reg *Registry, plugin party.Plugin) bool { if src, ok := plugin.(pact.AdminClientAssets); ok && src != nil && len(src.AdminJS()) > 0 { return true } if reg == nil || reg.fieldTypes == nil { return false } for _, ft := range reg.fieldTypes.byName { if ft.owner == plugin.ID() && len(ft.scripts) > 0 { return true } } return false } // compileExtensionAssets adds the JS and CSS a contributing plugin declares // through pact.AdminClientAssets to the target controller's assets, once per // plugin and controller, so its widget elements load with the form. func compileExtensionAssets(reg *Registry, cc *CompiledController, plugin party.Plugin, controller string) error { src, ok := plugin.(pact.AdminClientAssets) if !ok || src == nil { return nil } for _, ext := range cc.formExtensions { if ext.pluginID == plugin.ID() { return nil // added with the plugin's first extension } } assets, ok := plugin.(pact.AdminAssets) if !ok || assets == nil || assets.AdminFS() == nil { return extErr(plugin.ID(), controller, "pact.AdminClientAssets needs the plugin's AdminFS") } owner := "form extension of " + controller scripts, err := readClientAssets(plugin.ID(), owner, assets.AdminFS(), src.AdminJS(), ".js", ".mjs") if err != nil { return err } styles, err := readClientAssets(plugin.ID(), owner, assets.AdminFS(), src.AdminCSS(), ".css") if err != nil { return err } // New slices: the compiled ones may be shared. cc.scripts = append(append([]*pluginAsset(nil), cc.scripts...), scripts...) cc.styles = append(append([]*pluginAsset(nil), cc.styles...), styles...) if reg.assets == nil { reg.assets = map[string]*pluginAsset{} } for _, file := range append(append([]*pluginAsset(nil), scripts...), styles...) { if _, exists := reg.assets[file.key]; !exists { reg.assets[file.key] = file } } return nil } // compileExtensionPartials reads and parses the extension's partial // templates from Dir in the contributing plugin's AdminFS (D-91, RESEARCH // Pitfall 4). A name the owner or another extension already uses fails boot. func compileExtensionPartials(cc *CompiledController, plugin party.Plugin, ext *compiledFormExtension, names []string, controller string) error { if len(names) == 0 { return nil } pluginID := ext.pluginID if ext.def.PartialData == nil { return extErr(pluginID, controller, "partial %s: the form extension needs PartialData", names[0]) } assets, ok := plugin.(pact.AdminAssets) if !ok || assets == nil || assets.AdminFS() == nil { return extErr(pluginID, controller, "partial %s: the plugin has no AdminFS", names[0]) } dir := strings.Trim(path.Clean(strings.TrimSpace(ext.def.Dir)), "/") if dir == "." || dir == "" || strings.HasPrefix(dir, "..") { return extErr(pluginID, controller, "partial %s: Dir %q must name a directory in the plugin's AdminFS", names[0], ext.def.Dir) } ext.partials = map[string]*compiledPartial{} for _, name := range names { if _, done := ext.partials[name]; done { continue } if _, taken := cc.partials[name]; taken { return extErr(pluginID, controller, "partial %s: the owner already uses a partial with that name", name) } if other := cc.extensionPartials[name]; other != nil { return extErr(pluginID, controller, "partial %s: plugin %s already adds a partial with that name", name, other.pluginID) } file := path.Join(dir, "_"+name+".htm") src, err := readAsset(assets.AdminFS(), file) if err != nil { return extErr(pluginID, controller, "partial %s: template %s is not in the plugin's embedded files: %w", name, file, err) } compiled, err := parsePartial(name, src) if err != nil { return extErr(pluginID, controller, "partial %s: %w", name, err) } ext.partials[name] = compiled if cc.extensionPartials == nil { cc.extensionPartials = map[string]*compiledFormExtension{} } cc.extensionPartials[name] = ext } return nil } // checkFieldReferences checks the trigger and preset of one extension field // against the laid-out form, as checkTriggers and checkPresets do for the // owner's. func checkFieldReferences(fields []FormField, field FormField) error { types := make(map[string]string, len(fields)) for _, other := range fields { types[other.Name] = other.Type } if field.Trigger != nil { typ, ok := types[field.Trigger.Field] if !ok || field.Trigger.Field == field.Name { return fmt.Errorf("field %s: trigger field %s is not another field of this form", field.Name, field.Trigger.Field) } if typ != "switch" && typ != "checkbox" { return fmt.Errorf("field %s: trigger field %s must be a switch or checkbox, not type %s", field.Name, field.Trigger.Field, typ) } } if field.Preset != nil { typ, ok := types[field.Preset.Field] if !ok || field.Preset.Field == field.Name { return fmt.Errorf("field %s: preset field %s is not another field of this form", field.Name, field.Preset.Field) } if typ != "text" && typ != "mltext" { return fmt.Errorf("field %s: preset field %s must be a text or mltext field", field.Name, field.Preset.Field) } } return nil } // dropHiddenFields removes the fields extensions hide from the owner's // bindings, so show and save leave them out too. func dropHiddenFields(cc *CompiledController, hidden map[string]string) { if len(hidden) == 0 { return } writable := make([]WritableField, 0, len(cc.Writable)) for _, field := range cc.Writable { if _, gone := hidden[field.Name]; !gone { writable = append(writable, field) } } cc.Writable = writable for name := range hidden { delete(cc.files, name) delete(cc.dates, name) delete(cc.permissions, name) delete(cc.FieldRelations, name) } } // tabGroup is one tab of a form being laid out: its raw tab key ("" for the // default tab) and its fields in order. type tabGroup struct { key string fields []FormField } // formLayout is a form being laid out by its extensions: the fields grouped // by tab in first-appearance order, the default tab first (P-02), plus who // added each field and tab. type formLayout struct { groups []*tabGroup // fieldPlugin and tabPlugin map every field name and tab key to the // plugin that added it; the owner's map to "". Hidden owner fields stay, // so their names cannot be taken. fieldPlugin map[string]string tabPlugin map[string]string // hidden maps a hidden owner field to the plugin that hid it. hidden map[string]string // cursor maps an After anchor (field "f:" or tab "t:" plus the name) to // the last field or tab inserted after it, so several extensions // anchored at one point keep plugin order (P-05). cursor map[string]string requires map[string]map[string]bool } func newFormLayout(fields []FormField, requires map[string]map[string]bool) *formLayout { layout := &formLayout{ fieldPlugin: map[string]string{}, tabPlugin: map[string]string{}, hidden: map[string]string{}, cursor: map[string]string{}, requires: requires, } index := map[string]*tabGroup{} for _, field := range fields { group, ok := index[field.Tab] if !ok { group = &tabGroup{key: field.Tab} index[field.Tab] = group layout.groups = append(layout.groups, group) layout.tabPlugin[field.Tab] = "" } group.fields = append(group.fields, field) layout.fieldPlugin[field.Name] = "" } // The SPA always renders the default tab first. for i, group := range layout.groups { if group.key == "" && i > 0 { copy(layout.groups[1:i+1], layout.groups[:i]) layout.groups[0] = group break } } return layout } // flatten is the laid-out field list. func (l *formLayout) flatten() []FormField { out := []FormField{} for _, group := range l.groups { out = append(out, group.fields...) } return out } // groupIndex returns the index of the tab group with key, -1 when the form // has none. func (l *formLayout) groupIndex(key string) int { for i, group := range l.groups { if group.key == key { return i } } return -1 } // locate returns the group holding field name and its index there. func (l *formLayout) locate(name string) (*tabGroup, int) { for _, group := range l.groups { for i, field := range group.fields { if field.Name == name { return group, i } } } return nil, -1 } // required reports whether plugin may anchor on what owner added: the owner // controller's own fields and tabs, its own additions, or a plugin it lists // in Requires(). func (l *formLayout) required(plugin, owner string) bool { return owner == "" || owner == plugin || l.requires[plugin][owner] } // hide removes the owner fields the extension hides. A field another field // still references cannot go. func (l *formLayout) hide(ext *compiledFormExtension, controller string) error { for _, raw := range ext.def.Hide { name := strings.TrimSpace(raw) if _, done := l.hidden[name]; done { continue } owner, known := l.fieldPlugin[name] if !known { return extErr(ext.pluginID, controller, "Hide %q: the form has no such field", name) } if owner != "" { return extErr(ext.pluginID, controller, "Hide %q: the field belongs to plugin %s; only the owner's fields can be hidden", name, owner) } for _, group := range l.groups { for _, field := range group.fields { if via := fieldReference(field, name); via != "" { return extErr(ext.pluginID, controller, "Hide %q: field %s references it through %s", name, field.Name, via) } } } group, index := l.locate(name) if group != nil { group.fields = append(group.fields[:index:index], group.fields[index+1:]...) } l.hidden[name] = ext.pluginID } return nil } // fieldReference names the key through which field refers to name, "" when // it does not. func fieldReference(field FormField, name string) string { if field.Name == name { return "" } for _, key := range field.Fill { if key == name { return "fill" } } switch { case field.DependsOn == name: return "dependsOn" case field.Trigger != nil && field.Trigger.Field == name: return "trigger" case field.Preset != nil && field.Preset.Field == name: return "preset" case field.CurrencyFrom == name: return "currencyFrom" } return "" } // tabAnchorKey maps a tab anchor to its group key: default is "". func tabAnchorKey(name string) string { if name == defaultTabKey { return "" } return name } // addTabs places the extension's new tabs. func (l *formLayout) addTabs(ext *compiledFormExtension, tabs []extensionTab, controller string) error { pluginID := ext.pluginID for _, tab := range tabs { if err := checkZone(tab.zone); err != nil { return extErr(pluginID, controller, "tab %s: %w", tab.name, err) } if tab.name == defaultTabKey { return extErr(pluginID, controller, "tab %s: the name default is reserved for the tab of fields without a tab", tab.name) } if owner, taken := l.tabPlugin[tab.name]; taken { by := "the owner" if owner != "" { by = "plugin " + owner } return extErr(pluginID, controller, "tab %s: duplicate tab key (%s already has it)", tab.name, by) } if tab.before == defaultTabKey { return extErr(pluginID, controller, "tab %s: before: default is refused; the default tab always comes first", tab.name) } for _, anchor := range []string{tab.after, tab.before} { if anchor == "" || anchor == defaultTabKey { continue } owner, known := l.tabPlugin[anchor] if !known { return extErr(pluginID, controller, "tab %s: unknown tab anchor %q", tab.name, anchor) } if !l.required(pluginID, owner) { return extErr(pluginID, controller, "tab %s: anchor %q belongs to plugin %s, which plugin %s does not list in Requires()", tab.name, anchor, owner, pluginID) } } group := &tabGroup{key: tab.name} var at int switch { case tab.after != "": key := tabAnchorKey(tab.after) after := l.groupIndex(key) if last, ok := l.cursor["t:"+key]; ok { if i := l.groupIndex(last); i > after { after = i } } at = after + 1 l.cursor["t:"+key] = tab.name case tab.before != "": at = l.groupIndex(tab.before) default: at = len(l.groups) } l.groups = append(l.groups[:at], append([]*tabGroup{group}, l.groups[at:]...)...) if tab.after != "" && tab.before != "" && l.groupIndex(tab.before) < at { return extErr(pluginID, controller, "tab %s: cannot be after %q and before %q", tab.name, tab.after, tab.before) } l.tabPlugin[tab.name] = pluginID ext.tabs = append(ext.tabs, tab.name) } return nil } // addFields places the extension's fields: in an order where every field // anchored on another field of the same extension comes after it (a cycle // fails boot), each at its anchor or at the end of its tab. func (l *formLayout) addFields(ext *compiledFormExtension, fields []extensionField, controller string) error { pluginID := ext.pluginID own := map[string]bool{} for _, item := range fields { own[item.field.Name] = true } placed := map[string]bool{} remaining := append([]extensionField(nil), fields...) for len(remaining) > 0 { var next []extensionField for _, item := range remaining { waiting := false for _, anchor := range []string{item.after, item.before} { if anchor != "" && own[anchor] && !placed[anchor] { waiting = true } } if waiting { next = append(next, item) continue } if err := l.place(ext, item, controller); err != nil { return err } placed[item.field.Name] = true } if len(next) == len(remaining) { names := make([]string, len(next)) for i, item := range next { names[i] = item.field.Name } return extErr(pluginID, controller, "field %s: anchor cycle among fields %s", names[0], strings.Join(names, ", ")) } remaining = next } return nil } // place inserts one extension field. func (l *formLayout) place(ext *compiledFormExtension, item extensionField, controller string) error { pluginID := ext.pluginID field := item.field if err := checkZone(item.zone); err != nil { return extErr(pluginID, controller, "field %s: %w", field.Name, err) } if owner, taken := l.fieldPlugin[field.Name]; taken { by := "the owner" if owner != "" { by = "plugin " + owner } return extErr(pluginID, controller, "field %s: duplicate field name (%s already has it)", field.Name, by) } for _, anchor := range []string{item.after, item.before} { if anchor == "" { continue } if by, gone := l.hidden[anchor]; gone { return extErr(pluginID, controller, "field %s: anchor %q is hidden by plugin %s", field.Name, anchor, by) } owner, known := l.fieldPlugin[anchor] if !known { return extErr(pluginID, controller, "field %s: unknown anchor %q", field.Name, anchor) } if !l.required(pluginID, owner) { return extErr(pluginID, controller, "field %s: anchor %q belongs to plugin %s, which plugin %s does not list in Requires()", field.Name, anchor, owner, pluginID) } } if field.Tab != "" { owner, known := l.tabPlugin[field.Tab] if !known { return extErr(pluginID, controller, "field %s: unknown tab %q (declare a new tab under tabs:)", field.Name, field.Tab) } if !l.required(pluginID, owner) { return extErr(pluginID, controller, "field %s: tab %q belongs to plugin %s, which plugin %s does not list in Requires()", field.Name, field.Tab, owner, pluginID) } } var ( group *tabGroup at int ) switch { case item.after != "": var index int group, index = l.locate(item.after) at = index + 1 if last, ok := l.cursor["f:"+item.after]; ok { if lastGroup, i := l.locate(last); lastGroup == group && i >= at { at = i + 1 } } l.cursor["f:"+item.after] = field.Name case item.before != "": group, at = l.locate(item.before) default: index := l.groupIndex(field.Tab) if index < 0 { // Only the default tab can be missing here: the owner tabs every // field, and a declared tab always has a group. group = &tabGroup{} l.groups = append([]*tabGroup{group}, l.groups...) } else { group = l.groups[index] } at = len(group.fields) } if field.Tab == "" { field.Tab = group.key } else if field.Tab != group.key { return extErr(pluginID, controller, "field %s: its anchor is in tab %q, not %q", field.Name, displayTab(group.key), field.Tab) } group.fields = append(group.fields[:at:at], append([]FormField{field}, group.fields[at:]...)...) if item.after != "" && item.before != "" { before, index := l.locate(item.before) if before != group || index < at { return extErr(pluginID, controller, "field %s: cannot be after %q and before %q", field.Name, item.after, item.before) } } l.fieldPlugin[field.Name] = pluginID ext.fields = append(ext.fields, field.Name) ext.own[field.Name] = field return nil } // displayTab names a tab key in an error; the default tab is "default". func displayTab(key string) string { if key == "" { return defaultTabKey } return key } // checkFormExtensionTabLabels refuses two tab keys of an extended form whose // labels translate to the same text in a locale the phrasebook loads: the // admin keys tabs by their translated label, so they would merge (RESEARCH // Pitfall 1). A key without "::" is its own label. func checkFormExtensionTabLabels(reg *Registry, tr *phrasebook.Translator) error { if reg == nil || tr == nil { return nil } locales := tr.Locales("") for _, id := range sortedControllerIDs(reg) { cc := reg.byID[id] if len(cc.formExtensions) == 0 || cc.Form == nil { continue } declared := map[string]string{} for _, ext := range cc.formExtensions { for _, key := range ext.tabs { declared[key] = ext.pluginID } } if len(declared) == 0 { continue } var keys []string seen := map[string]bool{} for _, field := range cc.Form.Fields { if field.Tab != "" && !seen[field.Tab] { seen[field.Tab] = true keys = append(keys, field.Tab) } } for _, locale := range locales { labels := map[string]string{} for _, key := range keys { label := key if strings.Contains(key, "::") { label = tr.GetIn(locale, key, nil) } if other, clash := labels[label]; clash { plugin, ok := declared[key] if !ok { plugin = declared[other] } return extErr(plugin, id, "tab %s: its label reads %q in locale %s, like tab %s; the admin would merge them", key, label, locale, other) } labels[label] = key } } } return nil } // validateFormExtensionPermissions checks that every permission code a form // extension or its widget actions name is registered, like // compileContributions does for controllers. func validateFormExtensionPermissions(reg *Registry) error { if reg == nil { return nil } for _, id := range sortedControllerIDs(reg) { for _, ext := range reg.byID[id].formExtensions { if err := reg.validatePermissions("form extension of "+id+" by plugin "+ext.pluginID, ext.def.Permissions); err != nil { return err } for name, action := range ext.actions { if err := reg.validatePermissions("form extension action "+id+"."+name, action.Permissions); err != nil { return err } } } } return nil } // sortedControllerIDs are reg's controller IDs in sorted order. func sortedControllerIDs(reg *Registry) []string { ids := make([]string, 0, len(reg.byID)) for id := range reg.byID { ids = append(ids, id) } sort.Strings(ids) return ids } // extensionOf returns the form extension that added field name, nil for an // owner field. func (cc *CompiledController) extensionOf(name string) *compiledFormExtension { if cc == nil { return nil } return cc.extensionFields[name] } // extensionVisible reports whether principal sees ext's fields on this // request: it holds the extension's Permissions and Visible, when set, // answers true. func extensionVisible(ctx context.Context, principal *bouncer.Principal, ext *compiledFormExtension) bool { if ext == nil || !Allows(principal, ext.def.Permissions) { return false } return ext.def.Visible == nil || ext.def.Visible(ctx) } // visibleExtensions are the form extensions of cc that principal sees on // this request (D-91, RESEARCH Pitfall 7): the schema, show and save all use // it, so an extension hidden from the schema is inert on save too. func visibleExtensions(ctx context.Context, cc *CompiledController, principal *bouncer.Principal) map[*compiledFormExtension]bool { out := map[*compiledFormExtension]bool{} if cc == nil { return out } for _, ext := range cc.formExtensions { if extensionVisible(ctx, principal, ext) { out[ext] = true } } return out } // extensionLocales are the default content locale and the enabled ones for // translatable extension values: the translation writer's, or the admin's // locale alone without one. type extensionLocales struct { def string enabled map[string]bool writer bool } func resolveExtensionLocales(ctx context.Context, db *gorm.DB, writer TranslationWriter, tr *phrasebook.Translator) (extensionLocales, error) { if writer == nil { def := schemaLocale(context.Background(), tr) return extensionLocales{def: def, enabled: map[string]bool{def: true}}, nil } def, err := writer.DefaultLocale(ctx, db) if err != nil || def == "" { return extensionLocales{}, fmt.Errorf("cabana: default locale: %w", err) } codes, err := writer.EnabledLocales(ctx, db) if err != nil { return extensionLocales{}, fmt.Errorf("cabana: enabled locales: %w", err) } out := extensionLocales{def: def, enabled: map[string]bool{def: true}, writer: true} for _, code := range codes { out.enabled[code] = true } return out, nil } // extensionValues are the submitted values of one save, per extension. type extensionValues map[*compiledFormExtension]map[string]any // extensionLift is what liftExtensionValues needs besides the body. type extensionLift struct { CC *CompiledController Op string Visible map[*compiledFormExtension]bool Writer TranslationWriter Tr *phrasebook.Translator DB *gorm.DB } // liftExtensionValues takes the values of every extension field out of a // save body before anything else reads it, so they never reach Fill, the // owner's bindings or translation rows (D-91, T-32-01). The keys of an // extension the administrator does not see are dropped (T-32-02). A visible // extension keeps the values of its value fields whose context allows the // operation: a translatable one as a map of locale to text (a string is the // default locale's text), any other built-in type as a single JSON value, a // plugin field type as sent. func liftExtensionValues(ctx context.Context, in extensionLift, body map[string]any) (extensionValues, error) { out := extensionValues{} cc := in.CC if cc == nil || len(cc.extensionFields) == 0 || body == nil { return out, nil } var ( locales extensionLocales resolved bool ) details := map[string]any{} for _, ext := range cc.formExtensions { for _, name := range ext.fields { value, present := body[name] if !present { continue } delete(body, name) if !in.Visible[ext] || !ext.valueField(name) || !contextAllows(cc, name, in.Op) { continue } field := ext.own[name] if isMLField(field) { if !resolved { var err error if locales, err = resolveExtensionLocales(ctx, in.DB, in.Writer, in.Tr); err != nil { return nil, err } resolved = true } texts, message := extensionLocaleMap(name, value, locales) if message != "" { details[name] = []string{message} continue } value = texts } else if field.fieldType == nil && nestedValue(value) { details[name] = []string{"The " + name + " field must be a single value."} continue } if out[ext] == nil { out[ext] = map[string]any{} } out[ext][name] = value } } if len(details) > 0 { return nil, &ValidationError{Details: details} } return out, nil } // extensionLocaleMap turns a submitted translatable value into a map of // locale to text; message is the field error when it cannot. func extensionLocaleMap(name string, value any, locales extensionLocales) (map[string]string, string) { switch v := value.(type) { case nil: return map[string]string{locales.def: ""}, "" case string: return map[string]string{locales.def: v}, "" case map[string]any: if !locales.writer { return nil, "The " + name + " field cannot be translated." } out := make(map[string]string, len(v)) for locale, item := range v { text, ok := item.(string) if !ok || !locales.enabled[locale] { return nil, "The " + name + " field must be an object of locale codes to text." } out[locale] = text } return out, "" default: return nil, "The " + name + " field must be text or an object of locale codes to text." } } // extensionSave is what saveFormExtensions needs. type extensionSave struct { Tx *gorm.DB Tr *phrasebook.Translator Writer TranslationWriter CC *CompiledController Target any Values extensionValues Visible map[*compiledFormExtension]bool Created bool Changes []pact.RecordChange } // saveFormExtensions runs every visible extension's Save inside the // record's save transaction, after the owner row is written. A plugin field // type's Decode runs first on its value, per locale for a translatable one; // its notices join the save's. func saveFormExtensions(ctx context.Context, in extensionSave) ([]pluginNotice, error) { cc := in.CC if cc == nil || len(cc.formExtensions) == 0 { return nil, nil } var ( notices []pluginNotice locales extensionLocales resolved bool ) for _, ext := range cc.formExtensions { if ext.def.Save == nil || !in.Visible[ext] { continue } values := map[string]any{} for name, value := range in.Values[ext] { values[name] = value } for _, name := range ext.fields { field := ext.own[name] ft := field.fieldType value, ok := values[name] if ft == nil || !ok { continue } if !ft.def.Translatable { decoded, err := decodeExtensionValue(ctx, in, field, "", true, value, ¬ices) if err != nil { return nil, err } values[name] = decoded continue } if !resolved { var err error if locales, err = resolveExtensionLocales(ctx, in.Tx, in.Writer, in.Tr); err != nil { return nil, err } resolved = true } texts, _ := value.(map[string]string) out := make(map[string]string, len(texts)) for _, locale := range sortedLocales(texts) { decoded, err := decodeExtensionValue(ctx, in, field, locale, locale == locales.def, texts[locale], ¬ices) if err != nil { return nil, err } text, isText := decoded.(string) if !isText { return nil, opaqueFieldTypeFailure(cc, field, "decode", fmt.Errorf("translatable type returned %T, want a string", decoded)) } out[locale] = text } values[name] = out } result, err := ext.def.Save(ctx, pact.AdminFormSave{ DB: in.Tx, Controller: controllerID(cc), Record: in.Target, ID: recordIDText(in.Target), Created: in.Created, Values: values, Changes: append([]pact.RecordChange(nil), in.Changes...), }) if err != nil { return nil, extensionFailure(cc, ext, "save", err) } notices = append(notices, extensionNotices(result)...) } return notices, nil } // decodeExtensionValue runs a plugin field type's Decode on one extension // value and records its notices. func decodeExtensionValue(ctx context.Context, in extensionSave, field FormField, locale string, isDefault bool, value any, notices *[]pluginNotice) (any, error) { ft := field.fieldType decoded, err := ft.def.Decode(ctx, pact.FormFieldValue{ DB: in.Tx, Record: in.Target, Field: field.Name, Options: copyJSONMap(field.TypeOptions), Locale: locale, Default: isDefault, Value: value, Translated: translatedReader(ctx, in.Tx, in.Writer, in.Target, ft, locale), }) if err != nil { return nil, fieldTypeFailure(ctx, in.Tr, in.CC, field, "decode", err) } for _, notice := range decoded.Notices { *notices = append(*notices, pluginNotice{field: field.Name, locale: locale, notice: notice}) } return decoded.Value, nil } // sortedLocales are the keys of texts in sorted order. func sortedLocales(texts map[string]string) []string { out := make([]string, 0, len(texts)) for locale := range texts { out = append(out, locale) } sort.Strings(out) return out } // extensionNotices are a Save result's notices in field name order. func extensionNotices(result pact.AdminFormSaveResult) []pluginNotice { names := make([]string, 0, len(result.Notices)) for name := range result.Notices { names = append(names, name) } sort.Strings(names) var out []pluginNotice for _, name := range names { for _, notice := range result.Notices[name] { out = append(out, pluginNotice{field: name, notice: notice}) } } return out } // extensionFailure classifies an extension hook error: AdminFormErrors is // validation_failed with its messages on the fields (localized by the save // transaction), a cabana refusal passes through, anything else is logged and // becomes the opaque lifecycle error. func extensionFailure(cc *CompiledController, ext *compiledFormExtension, stage string, err error) error { var invalid pact.AdminFormErrors if errors.As(err, &invalid) && len(invalid) > 0 { details := make(map[string]any, len(invalid)) for name, messages := range invalid { details[name] = append([]string(nil), messages...) } return &ValidationError{Details: details} } var refused *ValidationError var forbidden *ForbiddenError if errors.As(err, &refused) || errors.As(err, &forbidden) { return err } moduleLog.Error("cabana: form extension "+stage+" failed", "controller", controllerID(cc), "plugin", ext.pluginID, "error", err) return &lifecycleError{ControllerID: controllerID(cc)} } // extensionHydrate is what hydrateFormExtensions needs. type extensionHydrate struct { Tx *gorm.DB Tr *phrasebook.Translator Writer TranslationWriter CC *CompiledController Target any Op string Visible map[*compiledFormExtension]bool } // hydrateFormExtensions adds every visible extension's Load values to a show // or save response: only the extension's value fields whose context allows // the operation, each then through its plugin field type's Load (per locale // for a translatable one). func hydrateFormExtensions(ctx context.Context, in extensionHydrate, data map[string]any) error { cc := in.CC if cc == nil || data == nil { return nil } var ( locales extensionLocales resolved bool ) for _, ext := range cc.formExtensions { if ext.def.Load == nil || !in.Visible[ext] { continue } loaded, err := ext.def.Load(ctx, pact.AdminFormLoad{DB: in.Tx, Controller: controllerID(cc), Record: in.Target, ID: recordIDText(in.Target)}) if err != nil { moduleLog.Error("cabana: form extension load failed", "controller", controllerID(cc), "plugin", ext.pluginID, "error", err) return &lifecycleError{ControllerID: controllerID(cc)} } for _, name := range ext.fields { value, ok := loaded[name] if !ok || !ext.valueField(name) || !contextAllows(cc, name, in.Op) { continue } field := ext.own[name] ft := field.fieldType if ft == nil || ft.def.Load == nil { data[name] = value continue } load := func(value any, locale string, isDefault bool) (any, error) { out, err := ft.def.Load(ctx, pact.FormFieldValue{ DB: in.Tx, Record: in.Target, Field: name, Options: copyJSONMap(field.TypeOptions), Locale: locale, Default: isDefault, Value: value, Translated: translatedReader(ctx, in.Tx, in.Writer, in.Target, ft, locale), }) if err != nil { return nil, opaqueFieldTypeFailure(cc, field, "load", err) } return out, nil } texts, isMap := value.(map[string]string) if !ft.def.Translatable || !isMap { served, err := load(derefValue(value), "", true) if err != nil { return err } data[name] = served continue } if !resolved { if locales, err = resolveExtensionLocales(ctx, in.Tx, in.Writer, in.Tr); err != nil { return err } resolved = true } served := make(map[string]any, len(texts)) for _, locale := range sortedLocales(texts) { if served[locale], err = load(texts[locale], locale, locale == locales.def); err != nil { return err } } data[name] = served } } return nil } // recordIDText is the record's primary key as text, "" when it has none. func recordIDText(model any) string { v := reflect.ValueOf(model) for v.Kind() == reflect.Pointer { if v.IsNil() { return "" } v = v.Elem() } if !v.IsValid() { return "" } field := fieldByColumn(v, primaryColumn(model)) if !field.IsValid() || !field.CanInterface() { return "" } value := derefValue(field.Interface()) if value == nil { return "" } return fmt.Sprint(value) } // principalOf is the principal on ctx, nil without one. func principalOf(ctx context.Context) *bouncer.Principal { principal, _ := bouncer.User(ctx) return principal }