From e7423bd21faf120490e42212b84e7f7843e10d13 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 26 Oct 2026 22:00:51 +0100 Subject: [PATCH] feat(32-01): form extension placement rules, boot errors and owner-aware branches - placement: tabs before/after tab anchors, anchor cursor for plugin order, own-field anchors sorted with cycle detection, Requires() rule, anchor tab inheritance, Hide with reference checks, zone refusal (D-101) - boot errors name plugin, controller and offending name; fileupload is refused (D-100); localized tab label collisions checked in Activate - extension widgets dispatch to AdminFormExtension.Actions, partials render from the contributor's Dir through PartialData, plugin field types decode for Save and load after Load, translatable values reach Save as locale maps and never the owner's translations - Permissions and Visible apply to schema, show, save, widgets and partials; AdminFormErrors answer 422, Save notices join meta.notices --- modules/cabana/actions.go | 14 +- modules/cabana/contracts.go | 3 + modules/cabana/crud.go | 16 +- modules/cabana/field_ml.go | 7 +- modules/cabana/field_type.go | 10 +- modules/cabana/form_extensions.go | 1113 ++++++++++++++++-- modules/cabana/form_extensions_smoke_test.go | 6 +- modules/cabana/http.go | 19 +- modules/cabana/partial_render.go | 24 +- modules/pact/capabilities.go | 14 +- 10 files changed, 1096 insertions(+), 130 deletions(-) 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