diff --git a/modules/cabana/actions.go b/modules/cabana/actions.go index d1a1b7de..1ab960f2 100644 --- a/modules/cabana/actions.go +++ b/modules/cabana/actions.go @@ -40,7 +40,19 @@ func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) { WriteError(w, http.StatusNotFound, "not_found", msgNotFound) return } - action, ok := cc.Actions[field.Action] + // D-91: a form extension's widget runs one of the extension's + // Actions, and exists only for an administrator who sees the + // extension (its Permissions and Visible). + actions := cc.Actions + if ext := cc.extensionOf(field.Name); ext != nil { + principal, _ := bouncer.User(r.Context()) + if !extensionVisible(r.Context(), principal, ext) { + WriteError(w, http.StatusNotFound, "not_found", msgNotFound) + return + } + actions = ext.actions + } + action, ok := actions[field.Action] if !ok { WriteError(w, http.StatusNotFound, "not_found", msgNotFound) return diff --git a/modules/cabana/contracts.go b/modules/cabana/contracts.go index 08a7a1ea..d0a5af6b 100644 --- a/modules/cabana/contracts.go +++ b/modules/cabana/contracts.go @@ -262,6 +262,9 @@ type CompiledController struct { // extension. formExtensions []*compiledFormExtension extensionFields map[string]*compiledFormExtension + // extensionPartials maps the name of every partial a form extension's + // partial field renders to its extension. + extensionPartials map[string]*compiledFormExtension } // Registry is the immutable controller map keyed by controller ID. diff --git a/modules/cabana/crud.go b/modules/cabana/crud.go index eeddf77c..22001d50 100644 --- a/modules/cabana/crud.go +++ b/modules/cabana/crud.go @@ -538,7 +538,8 @@ func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id if err := hydratePluginFields(ctx, tx, cc, s.writer, target, result.Data, "update"); err != nil { return err } - if err := hydrateFormExtensions(withTx(ctx, tx), tx, cc, target, result.Data, "update"); err != nil { + visible := visibleExtensions(ctx, cc, principalOf(ctx)) + if err := hydrateFormExtensions(withTx(ctx, tx), extensionHydrate{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Op: "update", Visible: visible}, result.Data); err != nil { return err } result.Meta.Actions, err = s.offeredRecordActions(withTx(ctx, tx), cc, target) @@ -756,8 +757,13 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i op = "update" } // D-91: form extension values leave the body first, so no owner path - // (Fill, bindings, translations, attachments) ever reads them. - extensions := liftExtensionValues(cc, in.Body, op) + // (Fill, bindings, translations, attachments) ever reads them; the keys + // of an extension this administrator does not see are dropped. + visible := visibleExtensions(ctx, cc, principalOf(ctx)) + extensions, err := liftExtensionValues(ctx, extensionLift{CC: cc, Op: op, Visible: visible, Writer: s.writer, Tr: s.tr, DB: s.DB}, in.Body) + if err != nil { + return RecordResult{}, err + } // Writable relation keys are lifted before scalar projection (which drops // every nested value); only keys present in the body are applied. relations, err := liftRelationValues(cc, in.Body, op) @@ -938,7 +944,7 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i } // D-91: form extensions save their own values in this transaction, // after the owner row is written; an error rolls everything back. - extNotices, err := saveFormExtensions(ctx, tx, cc, target, extensions, !update) + extNotices, err := saveFormExtensions(ctx, extensionSave{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Values: extensions, Visible: visible, Created: !update}) if err != nil { return err } @@ -953,7 +959,7 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i if err := hydratePluginFields(ctx, tx, cc, s.writer, target, result.Data, op); err != nil { return err } - return hydrateFormExtensions(ctx, tx, cc, target, result.Data, op) + return hydrateFormExtensions(ctx, extensionHydrate{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Op: op, Visible: visible}, result.Data) }) if err != nil { return RecordResult{}, err diff --git a/modules/cabana/field_ml.go b/modules/cabana/field_ml.go index a6832b55..5c75dca1 100644 --- a/modules/cabana/field_ml.go +++ b/modules/cabana/field_ml.go @@ -71,7 +71,9 @@ func liftMLValues(ctx context.Context, cc *CompiledController, body map[string]a } var names []string for _, field := range cc.Form.Fields { - if !isMLField(field) || !contextAllows(cc, field.Name, op) { + // A form extension's translatable field is lifted for its own Save + // (liftExtensionValues), never into the owner's translations. + if !isMLField(field) || !contextAllows(cc, field.Name, op) || cc.extensionOf(field.Name) != nil { continue } if _, present := body[field.Name]; present { @@ -193,7 +195,8 @@ func hydrateMLRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, w } var names []string for _, field := range cc.Form.Fields { - if !isMLField(field) || !contextAllows(cc, field.Name, op) { + // A form extension serves its own values (hydrateFormExtensions). + if !isMLField(field) || !contextAllows(cc, field.Name, op) || cc.extensionOf(field.Name) != nil { continue } names = append(names, field.Name) diff --git a/modules/cabana/field_type.go b/modules/cabana/field_type.go index 56b98527..15337165 100644 --- a/modules/cabana/field_type.go +++ b/modules/cabana/field_type.go @@ -384,7 +384,8 @@ func liftPluginValues(cc *CompiledController, body map[string]any, op string) ma } for _, field := range cc.Form.Fields { ft := field.fieldType - if ft == nil || ft.def.Translatable || !contextAllows(cc, field.Name, op) { + // A form extension's plugin field is decoded for its own Save. + if ft == nil || ft.def.Translatable || !contextAllows(cc, field.Name, op) || cc.extensionOf(field.Name) != nil { continue } value, present := body[field.Name] @@ -451,7 +452,9 @@ func decodePluginFields(ctx context.Context, in pluginDecodeInput) (pluginDecode } for _, field := range cc.Form.Fields { ft := field.fieldType - if ft == nil { + // A form extension's plugin field never reaches the host column: its + // value is decoded for the extension's Save (saveFormExtensions). + if ft == nil || cc.extensionOf(field.Name) != nil { continue } if !ft.def.Translatable { @@ -701,7 +704,8 @@ func hydratePluginFields(ctx context.Context, tx *gorm.DB, cc *CompiledControlle defaultKnown := false for _, field := range cc.Form.Fields { ft := field.fieldType - if ft == nil || ft.def.Load == nil || !contextAllows(cc, field.Name, op) { + // A form extension's plugin field is served by hydrateFormExtensions. + if ft == nil || ft.def.Load == nil || !contextAllows(cc, field.Name, op) || cc.extensionOf(field.Name) != nil { continue } stored, ok := data[field.Name] diff --git a/modules/cabana/form_extensions.go b/modules/cabana/form_extensions.go index b6cccefc..3f8df946 100644 --- a/modules/cabana/form_extensions.go +++ b/modules/cabana/form_extensions.go @@ -4,10 +4,12 @@ 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" @@ -20,6 +22,17 @@ import ( // 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 { @@ -29,6 +42,19 @@ type compiledFormExtension struct { // 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. @@ -52,7 +78,7 @@ type extensionTab struct { // extensionField is one compiled field of a fields document with its // before:, after: and zone: keys. type extensionField struct { - field FormField + field FormField before, after, zone string } @@ -64,14 +90,15 @@ var placementKeys = map[string]bool{"before": true, "after": true, "zone": true} // 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 each field -// is placed at its anchor. Every failure stops boot and names the plugin, the -// controller and the offending name. +// (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 { @@ -81,6 +108,11 @@ func compileFormExtensions(reg *Registry, plugins []party.Plugin) error { 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 @@ -99,7 +131,7 @@ func compileFormExtensions(reg *Registry, plugins []party.Plugin) error { } } for _, id := range order { - if err := applyFormExtensions(reg, reg.byID[id], targets[id]); err != nil { + if err := applyFormExtensions(reg, reg.byID[id], targets[id], requires); err != nil { return err } } @@ -112,7 +144,7 @@ func extErr(pluginID, controller, format string, args ...any) error { } // applyFormExtensions compiles the contributions of one target controller. -func applyFormExtensions(reg *Registry, cc *CompiledController, contributions []formContribution) error { +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 @@ -126,10 +158,19 @@ func applyFormExtensions(reg *Registry, cc *CompiledController, contributions [] layout = true } } - form := newFormLayout(cc.Form.Fields) + 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 := form.apply(ext, docs[i], controller); err != nil { + 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) @@ -140,10 +181,19 @@ func applyFormExtensions(reg *Registry, cc *CompiledController, contributions [] cc.extensionFields[name] = ext } } - if layout { - // A new slice: the owner's compiled list may be shared. - cc.Form.Fields = form.flatten() + 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 } @@ -325,6 +375,271 @@ func decodeExtensionTabs(node ast.Node) ([]extensionTab, error) { 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 { @@ -334,16 +649,31 @@ type tabGroup struct { // 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. +// added each field and tab. type formLayout struct { groups []*tabGroup - // fieldPlugin maps every field name to the plugin that added it; owner - // fields map to "". + // 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) *formLayout { - layout := &formLayout{fieldPlugin: map[string]string{}} +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] @@ -351,6 +681,7 @@ func newFormLayout(fields []FormField) *formLayout { 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] = "" @@ -375,14 +706,15 @@ func (l *formLayout) flatten() []FormField { return out } -// group returns the tab group with key, nil when the form has none. -func (l *formLayout) group(key string) *tabGroup { - for _, group := range l.groups { +// 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 group + return i } } - return nil + return -1 } // locate returns the group holding field name and its index there. @@ -397,61 +729,354 @@ func (l *formLayout) locate(name string) (*tabGroup, int) { return nil, -1 } -// apply places one extension's fields. -func (l *formLayout) apply(ext *compiledFormExtension, doc extensionDoc, controller string) error { - pluginID := ext.pluginID - if len(doc.tabs) > 0 { - return extErr(pluginID, controller, "tab %s: new tabs are not supported yet", doc.tabs[0].name) +// 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 } - for _, item := range doc.fields { - field := item.field - if owner, taken := l.fieldPlugin[field.Name]; taken { + 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, "field %s: duplicate field name (%s already adds it)", field.Name, by) + return extErr(pluginID, controller, "tab %s: duplicate tab key (%s already has it)", tab.name, by) } - anchor, before := item.after, false - if anchor == "" && item.before != "" { - anchor, before = item.before, true + if tab.before == defaultTabKey { + return extErr(pluginID, controller, "tab %s: before: default is refused; the default tab always comes first", tab.name) } - var ( - group *tabGroup - index int - ) - if anchor != "" { - group, index = l.locate(anchor) - if group == nil { - return extErr(pluginID, controller, "field %s: unknown anchor %q", field.Name, anchor) + for _, anchor := range []string{tab.after, tab.before} { + if anchor == "" || anchor == defaultTabKey { + continue } - if field.Tab == "" { - field.Tab = group.key - } else if field.Tab != group.key { - return extErr(pluginID, controller, "field %s: anchor %q is in another tab than %q", field.Name, anchor, field.Tab) + owner, known := l.tabPlugin[anchor] + if !known { + return extErr(pluginID, controller, "tab %s: unknown tab anchor %q", tab.name, anchor) } - if !before { - index++ + 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) } - } else { - group = l.group(field.Tab) - if group == nil { - if field.Tab != "" { - return extErr(pluginID, controller, "field %s: unknown tab %q", field.Name, field.Tab) + } + 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 } - group = &tabGroup{} - l.groups = append([]*tabGroup{group}, l.groups...) } - index = len(group.fields) + at = after + 1 + l.cursor["t:"+key] = tab.name + case tab.before != "": + at = l.groupIndex(tab.before) + default: + at = len(l.groups) } - group.fields = append(group.fields[:index], append([]FormField{field}, group.fields[index:]...)...) - l.fieldPlugin[field.Name] = pluginID - ext.fields = append(ext.fields, field.Name) - ext.own[field.Name] = field + 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 { @@ -461,57 +1086,238 @@ func (cc *CompiledController) extensionOf(name string) *compiledFormExtension { return cc.extensionFields[name] } -// extensionValues are the submitted values of one save, per extension. -type extensionValues map[*compiledFormExtension]map[string]any +// 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) +} -// 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). Only fields whose -// context allows op are kept for their extension's Save. -func liftExtensionValues(cc *CompiledController, body map[string]any, op string) extensionValues { - out := extensionValues{} - if cc == nil || len(cc.extensionFields) == 0 || body == nil { +// 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 name, ext := range cc.extensionFields { - value, present := body[name] - if !present { - continue + for _, ext := range cc.formExtensions { + if extensionVisible(ctx, principal, ext) { + out[ext] = true } - delete(body, name) - if !contextAllows(cc, name, op) { - continue - } - if out[ext] == nil { - out[ext] = map[string]any{} - } - out[ext][name] = value } return out } -// saveFormExtensions runs every extension's Save inside the record's save -// transaction, after the owner row is written. created is true on create. -func saveFormExtensions(ctx context.Context, tx *gorm.DB, cc *CompiledController, target any, values extensionValues, created bool) ([]pluginNotice, error) { - if cc == nil { +// 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 +} + +// 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 + var ( + notices []pluginNotice + locales extensionLocales + resolved bool + ) for _, ext := range cc.formExtensions { - if ext.def.Save == nil { + if ext.def.Save == nil || !in.Visible[ext] { continue } - own := values[ext] - if own == nil { - own = map[string]any{} + 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: tx, + DB: in.Tx, Controller: controllerID(cc), - Record: target, - ID: recordIDText(target), - Created: created, - Values: own, + Record: in.Target, + ID: recordIDText(in.Target), + Created: in.Created, + Values: values, }) if err != nil { return nil, extensionFailure(cc, ext, "save", err) @@ -521,14 +1327,47 @@ func saveFormExtensions(ctx context.Context, tx *gorm.DB, cc *CompiledController 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 { - var out []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}) @@ -538,8 +1377,9 @@ func extensionNotices(result pact.AdminFormSaveResult) []pluginNotice { } // extensionFailure classifies an extension hook error: AdminFormErrors is -// validation_failed with its messages on the fields, a cabana refusal passes -// through, anything else is logged and becomes the opaque lifecycle error. +// 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 { @@ -558,28 +1398,83 @@ func extensionFailure(cc *CompiledController, ext *compiledFormExtension, stage return &lifecycleError{ControllerID: controllerID(cc)} } -// hydrateFormExtensions adds every extension's Load values to a show or save -// response. Only keys that are the extension's own fields whose context -// allows op are kept. -func hydrateFormExtensions(ctx context.Context, tx *gorm.DB, cc *CompiledController, target any, data map[string]any, op string) error { +// 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 { + if ext.def.Load == nil || !in.Visible[ext] { continue } - loaded, err := ext.def.Load(ctx, pact.AdminFormLoad{DB: tx, Controller: controllerID(cc), Record: target, ID: recordIDText(target)}) + 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 || !contextAllows(cc, name, op) { + if !ok || !ext.valueField(name) || !contextAllows(cc, name, in.Op) { continue } - data[name] = value + 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 @@ -608,8 +1503,8 @@ func recordIDText(model any) string { return fmt.Sprint(value) } -// checkFormExtensionTabLabels refuses two tab keys of an extended form whose -// labels translate to the same text in a locale the phrasebook loads. -func checkFormExtensionTabLabels(reg *Registry, tr *phrasebook.Translator) error { - return nil +// principalOf is the principal on ctx, nil without one. +func principalOf(ctx context.Context) *bouncer.Principal { + principal, _ := bouncer.User(ctx) + return principal } diff --git a/modules/cabana/form_extensions_smoke_test.go b/modules/cabana/form_extensions_smoke_test.go index 860aea94..ea3f206b 100644 --- a/modules/cabana/form_extensions_smoke_test.go +++ b/modules/cabana/form_extensions_smoke_test.go @@ -71,7 +71,7 @@ func (p feOwnerPlugin) AdminFS() fs.FS { return fstest.MapFS{ "controllers/posts/config_form.yaml": &fstest.MapFile{Data: []byte("name: Post\nform: ~/plugins/" + dir + "/models/post/fields.yaml\nmodelClass: Post\n")}, "controllers/posts/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/" + dir + "/models/post/columns.yaml\nmodelClass: Post\nrecordsPerPage: 20\n")}, - "controllers/posts/_summary.htm": &fstest.MapFile{Data: []byte("

{{ .title }}

\n")}, + "controllers/posts/_summary.htm": &fstest.MapFile{Data: []byte("

{{ .Data.title }}

\n")}, "models/post/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n a:\n label: A\n")}, "models/post/fields.yaml": &fstest.MapFile{Data: []byte(p.fields)}, "assets/js/owner.js": &fstest.MapFile{Data: []byte("// owner\n")}, @@ -429,8 +429,8 @@ func feExtPartialPlugin(doc string) feContributor { } }) ext.fsys = fstest.MapFS{ - "partials/_summary.htm": &fstest.MapFile{Data: []byte("

{{ .title }}

\n")}, - "partials/_status.htm": &fstest.MapFile{Data: []byte("

{{ .title }}

\n")}, + "partials/_summary.htm": &fstest.MapFile{Data: []byte("

{{ .Data.title }}

\n")}, + "partials/_status.htm": &fstest.MapFile{Data: []byte("

{{ .Data.title }}

\n")}, "partials/_leak.htm": &fstest.MapFile{Data: []byte("

leak

\n")}, } return ext diff --git a/modules/cabana/http.go b/modules/cabana/http.go index 17c3b922..3343b2e0 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -149,6 +149,9 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) { if err := compileContributions(reg, plugins); err != nil { return nil, err } + if err := validateFormExtensionPermissions(reg); err != nil { + return nil, err + } screens.reg = reg if err := compileTokenScopes(reg, plugins); err != nil { return nil, err @@ -163,6 +166,9 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) { if err := validateTokenScopeMessageKeys(reg, tr); err != nil { return nil, err } + if err := checkFormExtensionTabLabels(reg, tr); err != nil { + return nil, err + } } guards, ok := app.Lookup[*bouncer.Registry]() if !ok || guards == nil { @@ -813,13 +819,24 @@ func (s *service) formSchema(w http.ResponseWriter, r *http.Request) { // localized view must never share its backing array with the cache. principal, _ := bouncer.User(r.Context()) hidden := hiddenSchemaFields(r.Context(), cc) + // D-91: an extension this administrator does not see (its + // Permissions or Visible) contributes no field. + visible := visibleExtensions(r.Context(), cc, principal) kept := make([]FormField, 0, len(view.Fields)) for _, field := range view.Fields { if hidden[field.Name] { continue } + ext := cc.extensionOf(field.Name) + if ext != nil && !visible[ext] { + continue + } if field.Type == "widget" { - action, ok := cc.Actions[field.Action] + actions := cc.Actions + if ext != nil { + actions = ext.actions + } + action, ok := actions[field.Action] if !ok || !Allows(principal, action.Permissions) { continue } diff --git a/modules/cabana/partial_render.go b/modules/cabana/partial_render.go index 1697460b..4952b378 100644 --- a/modules/cabana/partial_render.go +++ b/modules/cabana/partial_render.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" + "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/towel" @@ -500,8 +501,23 @@ func (s *service) partial(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { name := r.PathValue("name") compiled, ok := cc.partials[name] - provider, isProvider := cc.Controller.(pact.AdminPartialData) - if !ok || !isProvider || provider == nil { + var data func(ctx context.Context, name string, record any) (any, error) + if provider, isProvider := cc.Controller.(pact.AdminPartialData); isProvider && provider != nil { + data = provider.PartialData + } + formPartial := cc.formPartials[name] + // D-91: a form extension's partial renders from the contributing + // plugin's template through its PartialData, only for an + // administrator who sees the extension. + if ext := cc.extensionPartials[name]; ext != nil { + principal, _ := bouncer.User(r.Context()) + if !extensionVisible(r.Context(), principal, ext) { + WriteError(w, http.StatusNotFound, "not_found", msgNotFound) + return + } + compiled, ok, data, formPartial = ext.partials[name], true, ext.def.PartialData, true + } + if !ok || data == nil { WriteError(w, http.StatusNotFound, "not_found", msgNotFound) return } @@ -509,7 +525,7 @@ func (s *service) partial(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() if query.Has("id") { id, err := strconv.ParseUint(query.Get("id"), 10, 64) - if err != nil || id == 0 || !cc.formPartials[name] { + if err != nil || id == 0 || !formPartial { WriteError(w, http.StatusNotFound, "not_found", msgNotFound) return } @@ -530,7 +546,7 @@ func (s *service) partial(w http.ResponseWriter, r *http.Request) { moduleLog.Error("cabana: admin partial failed", "controller", controllerID(cc), "partial", name, "reason", reason, "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) } - vm, err := provider.PartialData(ctx, name, record) + vm, err := data(ctx, name, record) if err != nil { fail("view model", err) return diff --git a/modules/pact/capabilities.go b/modules/pact/capabilities.go index 1e620f95..15f46670 100644 --- a/modules/pact/capabilities.go +++ b/modules/pact/capabilities.go @@ -464,6 +464,12 @@ const ( // boot. Set at most one of them; an extension with neither adds no field and // may still hide owner fields and run Load and Save. // +// An extension adds value fields (text, textarea, number, checkbox, switch, +// dropdown with listed options, markdown, mltext, mltextarea, mlmarkdown and +// plugin field types), widget fields and partial fields; types that bind to +// the owner's model (fileupload, relation, repeater and the like) are +// refused, and an extension with value fields needs Save. +// // Placement: a field goes Before or After its anchor field, or to the end of // its tab without one; a field without tab: takes its anchor's tab. A new tab // goes Before or After its anchor tab, or after the last tab without one; @@ -535,8 +541,12 @@ type AdminFormLoad struct { // record's save transaction (cabana.TxFromContext reads it too); the owner row // is already written, so Record carries its primary key, also given as ID. // Created is true on create. Values are the submitted values of the -// extension's fields whose context allows the operation, keyed by field name; -// a field the request did not send is absent. +// extension's value fields whose context allows the operation, keyed by field +// name; a field the request did not send is absent. A value is as decoded from +// the JSON body (numbers as json.Number), a translatable field's a +// map[string]string of locale to text, a plugin field type's the value its +// Decode returned. Save runs on every create and update of a record whose +// administrator sees the extension, with or without values. type AdminFormSave struct { DB *gorm.DB Controller string