package cabana import ( "bytes" "encoding/json" "errors" "fmt" "io/fs" "path" "strconv" "strings" "context" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/towel" "github.com/goccy/go-yaml" "github.com/goccy/go-yaml/ast" ) var ( formFieldTypes = map[string]struct{}{ "text": {}, "textarea": {}, "number": {}, "checkbox": {}, "switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {}, "widget": {}, "partial": {}, } formSpans = map[string]struct{}{ "left": {}, "right": {}, "full": {}, "auto": {}, "row": {}, } formSizes = map[string]struct{}{ "tiny": {}, "small": {}, "large": {}, "huge": {}, "giant": {}, } formFieldKeys = map[string]struct{}{ "label": {}, "comment": {}, "span": {}, "type": {}, "required": {}, "tab": {}, "context": {}, "attributes": {}, "size": {}, "default": {}, "nameFrom": {}, "emptyOption": {}, "options": {}, "relation": {}, "widget": {}, "action": {}, "fill": {}, "path": {}, } // widgetKeys are valid only on `type: widget` (D-06). widgetKeys = []string{"widget", "action", "fill"} ) type formConfigDocument struct { Name string `yaml:"name"` Form string `yaml:"form"` ModelClass string `yaml:"modelClass"` DefaultRedirect string `yaml:"defaultRedirect"` Create *formRedirects `yaml:"create"` Update *formRedirects `yaml:"update"` Messages *formMessageKeys `yaml:"messages"` } type formRedirects struct { Redirect string `yaml:"redirect"` RedirectClose string `yaml:"redirectClose"` } type formFieldsFile struct { Fields fieldMap `yaml:"fields"` } type fieldMap struct { items []FormField } // CompileForm compiles config_form.yaml and the model fields.yaml it names. // The returned schema is safe to cache: labels stay as source keys. func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) { if ctl == nil { return nil, fmt.Errorf("cabana: admin schema %s: controller is nil", pluginID) } dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/") if dir == "." || strings.HasPrefix(dir, "..") { return nil, bootErr(pluginID, ctl.ID(), ctl.ConfigDir(), fmt.Errorf("config directory escapes the plugin")) } cfgPath := dir + "/config_form.yaml" raw, err := readAsset(fsys, cfgPath) if err != nil { return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) } var doc formConfigDocument if err := decodeStrict(raw, &doc); err != nil { return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) } if doc.ModelClass != ctl.ModelName() { return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("modelClass %q does not match %q", doc.ModelClass, ctl.ModelName())) } if strings.TrimSpace(doc.Form) == "" { return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("form file is empty")) } fieldsPath, err := assetPath(pluginID, doc.Form) if err != nil { return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) } fieldsRaw, err := readAsset(fsys, fieldsPath) if err != nil { return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err) } fields, err := decodeFields(fieldsRaw) if err != nil { return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err) } if fields == nil { fields = []FormField{} } if err := requireDropdownProvider(ctl, fields); err != nil { return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err) } schema := &FormSchema{ Name: doc.Name, ModelClass: doc.ModelClass, Fields: fields, redirects: FormRedirects{Default: doc.DefaultRedirect}, fieldsPath: fieldsPath, } if doc.Messages != nil { schema.messageKeys = *doc.Messages } if doc.Create != nil { schema.redirects.Create = FormRedirect{Redirect: doc.Create.Redirect, RedirectClose: doc.Create.RedirectClose} } if doc.Update != nil { schema.redirects.Update = FormRedirect{Redirect: doc.Update.Redirect, RedirectClose: doc.Update.RedirectClose} } return schema, nil } // Localize copies the cached schema and resolves display strings for one request. // It does not mutate s. Option values keep the scalar captured at boot. func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, provider pact.DropdownOptionsProvider) (*FormView, error) { if s == nil { return nil, fmt.Errorf("cabana: nil form schema") } if ctx == nil { ctx = context.Background() } locale := schemaLocale(ctx, tr) ctx = towel.WithLocale(ctx, locale) fields := make([]FormField, len(s.Fields)) for i, src := range s.Fields { field := src field.Label = translateKey(ctx, tr, src.Label) field.Comment = translateKey(ctx, tr, src.Comment) field.Tab = translateKey(ctx, tr, src.Tab) field.EmptyOption = translateKey(ctx, tr, src.EmptyOption) field.ActionLabel = translateKey(ctx, tr, src.ActionLabel) field.Fill = append([]string(nil), src.Fill...) options, err := localizeOptions(ctx, tr, src, provider) if err != nil { return nil, err } field.Options = options fields[i] = field } if fields == nil { fields = []FormField{} } return &FormView{ Name: translateKey(ctx, tr, s.Name), ModelClass: s.ModelClass, Fields: fields, Messages: localizeMessages[formMessageKeys, FormMessages](ctx, tr, s.formMessageKeySet()), Redirects: s.redirects, Meta: FormMeta{Locale: locale}, }, nil } // CanonicalJSON is one localized schema response. func (v *FormView) CanonicalJSON() ([]byte, error) { if v == nil { return nil, fmt.Errorf("cabana: nil form view") } out := *v if out.Fields == nil { out.Fields = []FormField{} } return json.Marshal(out) } // CanonicalJSON is the stable, locale-neutral form contract. func (s *FormSchema) CanonicalJSON() ([]byte, error) { if s == nil { return nil, fmt.Errorf("cabana: nil form schema") } out := *s if out.Fields == nil { out.Fields = []FormField{} } return json.Marshal(out) } func requireDropdownProvider(ctl pact.AdminController, fields []FormField) error { var method string for _, field := range fields { if field.optionsMethod != "" { method = field.optionsMethod break } } if method == "" || dropdownProvider(ctl) != nil { return nil } return fmt.Errorf("dropdown method %s requires DropdownOptions", method) } func dropdownProvider(ctl pact.AdminController) pact.DropdownOptionsProvider { if ctl == nil { return nil } if p, ok := ctl.(pact.DropdownOptionsProvider); ok && p != nil { return p } src, ok := ctl.(pact.AdminRecordSource) if !ok || src == nil { return nil } rec := src.NewRecord() p, ok := rec.(pact.DropdownOptionsProvider) if !ok || p == nil { return nil } return p } func schemaLocale(ctx context.Context, tr *phrasebook.Translator) string { if loc, ok := towel.Locale(ctx); ok { if primary := primaryLanguage(loc); primary != "" { return primary } } if tr != nil { if loc := strings.TrimSpace(tr.Locale()); loc != "" { return loc } } return "en" } func primaryLanguage(header string) string { header = strings.TrimSpace(header) if header == "" { return "" } if i := strings.IndexAny(header, ",;"); i >= 0 { header = strings.TrimSpace(header[:i]) } return header } func translateKey(ctx context.Context, tr *phrasebook.Translator, key string) string { if key == "" || tr == nil { return key } return tr.Get(ctx, key, nil) } func localizeOptions(ctx context.Context, tr *phrasebook.Translator, src FormField, provider pact.DropdownOptionsProvider) ([]FormOption, error) { if src.optionsMethod != "" { if provider == nil { return nil, fmt.Errorf("cabana: dropdown method %s requires DropdownOptions", src.optionsMethod) } provided := provider.DropdownOptions(src.Name) opts := make([]FormOption, len(provided)) for i, opt := range provided { raw, err := json.Marshal(opt.Value) if err != nil { return nil, err } opts[i] = FormOption{Value: jsonScalar{raw: raw}, Label: translateKey(ctx, tr, opt.Label)} } return opts, nil } if len(src.Options) == 0 { return nil, nil } opts := make([]FormOption, len(src.Options)) for i, opt := range src.Options { opt.Label = translateKey(ctx, tr, opt.Label) opts[i] = opt } return opts, nil } func decodeFields(raw []byte) ([]FormField, error) { dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField()) var doc formFieldsFile if err := dec.Decode(&doc); err != nil { return nil, normalizeYAMLError(err) } if doc.Fields.items == nil { return []FormField{}, nil } return doc.Fields.items, nil } func (m *fieldMap) UnmarshalYAML(node ast.Node) error { node = unwrapNode(node) if _, ok := node.(*ast.NullNode); ok || node == nil { m.items = []FormField{} return nil } mapping, ok := node.(*ast.MappingNode) if !ok { return fmt.Errorf("fields must be a mapping") } items := make([]FormField, 0, len(mapping.Values)) seen := map[string]struct{}{} for _, entry := range mapping.Values { name, err := nodeString(unwrapNode(entry.Key)) if err != nil || !identifier(name) { return fmt.Errorf("field name %q is not an identifier", nodeText(entry.Key)) } if _, dup := seen[name]; dup { return fmt.Errorf("duplicate field %s", name) } seen[name] = struct{}{} field, err := compileFieldNode(name, unwrapNode(entry.Value)) if err != nil { return fmt.Errorf("field %s: %w", name, err) } items = append(items, field) } m.items = items return nil } func compileFieldNode(name string, node ast.Node) (FormField, error) { mapping, ok := node.(*ast.MappingNode) if !ok { return FormField{}, fmt.Errorf("must be a mapping") } values := map[string]ast.Node{} for _, entry := range mapping.Values { key, err := nodeString(unwrapNode(entry.Key)) if err != nil { return FormField{}, err } if _, known := formFieldKeys[key]; !known { return FormField{}, fmt.Errorf("unknown field %s", key) } if _, dup := values[key]; dup { return FormField{}, fmt.Errorf("duplicate field %s", key) } values[key] = unwrapNode(entry.Value) } typ, err := nodeString(values["type"]) if err != nil || typ == "" { return FormField{}, fmt.Errorf("type is required") } if _, ok := formFieldTypes[typ]; !ok { return FormField{}, fmt.Errorf("unsupported type %s", typ) } field := FormField{Name: name, Type: typ} if node, ok := values["label"]; ok { field.Label, err = nodeString(node) if err != nil { return FormField{}, fmt.Errorf("label: %w", err) } } if node, ok := values["comment"]; ok { field.Comment, err = nodeString(node) if err != nil { return FormField{}, fmt.Errorf("comment: %w", err) } } if node, ok := values["span"]; ok { field.Span, err = nodeString(node) if err != nil { return FormField{}, fmt.Errorf("span: %w", err) } if _, ok := formSpans[field.Span]; !ok { return FormField{}, fmt.Errorf("unsupported span %s", field.Span) } } if node, ok := values["tab"]; ok { field.Tab, err = nodeString(node) if err != nil { return FormField{}, fmt.Errorf("tab: %w", err) } } if node, ok := values["size"]; ok { field.Size, err = nodeString(node) if err != nil { return FormField{}, fmt.Errorf("size: %w", err) } if _, ok := formSizes[field.Size]; !ok { return FormField{}, fmt.Errorf("unsupported size %s", field.Size) } } if node, ok := values["context"]; ok { ctx, err := compileContext(node) if err != nil { return FormField{}, err } field.Context = ctx } if node, ok := values["nameFrom"]; ok { field.NameFrom, err = nodeString(node) if err != nil || !identifier(field.NameFrom) { return FormField{}, fmt.Errorf("nameFrom %q is not an identifier", field.NameFrom) } } if node, ok := values["emptyOption"]; ok { field.EmptyOption, err = nodeString(node) if err != nil { return FormField{}, fmt.Errorf("emptyOption: %w", err) } } if node, ok := values["relation"]; ok { field.Relation, err = nodeString(node) if err != nil || !identifier(field.Relation) { return FormField{}, fmt.Errorf("relation %q is not an identifier", field.Relation) } } if typ == "relation-manager" && field.Relation == "" { return FormField{}, fmt.Errorf("relation is required") } if err := compileWidgetKeys(typ, values, &field); err != nil { return FormField{}, err } if err := compilePartialPath(typ, values, &field); err != nil { return FormField{}, err } if node, ok := values["required"]; ok { field.Required, err = nodeBool(node) if err != nil { return FormField{}, fmt.Errorf("required: %w", err) } } if node, ok := values["default"]; ok { scalar, err := nodeScalar(node) if err != nil { return FormField{}, fmt.Errorf("default: %w", err) } field.Default = &scalar } if node, ok := values["attributes"]; ok { attrs, err := compileAttributes(node) if err != nil { return FormField{}, fmt.Errorf("attributes: %w", err) } field.Attributes = attrs } if node, ok := values["options"]; ok { if err := compileOptions(node, &field); err != nil { return FormField{}, fmt.Errorf("options: %w", err) } } return field, nil } // compileWidgetKeys decodes the D-06 widget keys. They are refused on any // other type; a widget needs a custom-element tag and an action name, and may // list the fields its action writes back. The tag prefix, the registered // action and the fill fields are checked against the controller in // compileExtension. func compileWidgetKeys(typ string, values map[string]ast.Node, field *FormField) error { if typ != "widget" { for _, key := range widgetKeys { if _, ok := values[key]; ok { return fmt.Errorf("%s is only valid on type: widget", key) } } return nil } tag, err := nodeString(values["widget"]) if err != nil || strings.TrimSpace(tag) == "" { return fmt.Errorf("widget (the custom-element tag) is required on type: widget") } field.Widget = tag action, err := nodeString(values["action"]) if err != nil || !identifier(action) { return fmt.Errorf("action %q is not an identifier (type: widget needs a registered action)", nodeText(values["action"])) } field.Action = action if node, ok := values["fill"]; ok { seq, ok := node.(*ast.SequenceNode) if !ok { return fmt.Errorf("fill must be a list of field names") } items := sequenceValues(seq) fill := make([]string, 0, len(items)) seen := map[string]struct{}{} for _, item := range items { name, err := nodeString(unwrapNode(item)) if err != nil || !identifier(name) { return fmt.Errorf("fill %q is not a field name", nodeText(item)) } if _, dup := seen[name]; dup { return fmt.Errorf("fill: duplicate field %s", name) } seen[name] = struct{}{} fill = append(fill, name) } field.Fill = fill } return nil } // partialPathHint is the D-11 path rule shared by every partial path error. const partialPathHint = "path must be a partial name such as summary (resolves to CONFIG_DIR/_summary.htm); Winter $/ and ~/ paths are not supported" // compilePartialPath decodes the path of a `type: partial` field (D-09). It // is refused on any other type and must be a bare partial name, so no // free-form path ever reaches the plugin's file tree. The template itself is // read and parsed in compileExtension. func compilePartialPath(typ string, values map[string]ast.Node, field *FormField) error { node, ok := values["path"] if typ != "partial" { if ok { return fmt.Errorf("path is only valid on type: partial") } return nil } if !ok { return fmt.Errorf("type partial needs a path: %s", partialPathHint) } name, err := nodeString(node) if err != nil || !identifier(name) { return fmt.Errorf("partial %q: %s", nodeText(node), partialPathHint) } field.Path = name return nil } func compileContext(node ast.Node) (*fieldContext, error) { switch n := node.(type) { case *ast.StringNode: if !identifier(n.Value) { return nil, fmt.Errorf("context %q is not an identifier", n.Value) } return &fieldContext{single: true, values: []string{n.Value}}, nil case *ast.SequenceNode: values := sequenceValues(n) out := make([]string, 0, len(values)) for _, item := range values { text, err := nodeString(unwrapNode(item)) if err != nil || !identifier(text) { return nil, fmt.Errorf("context %q is not an identifier", nodeText(item)) } out = append(out, text) } return &fieldContext{values: out}, nil default: return nil, fmt.Errorf("context must be a string or a list") } } func compileAttributes(node ast.Node) (map[string]jsonScalar, error) { mapping, ok := node.(*ast.MappingNode) if !ok { return nil, fmt.Errorf("must be a mapping") } out := make(map[string]jsonScalar, len(mapping.Values)) for _, entry := range mapping.Values { key, err := nodeString(unwrapNode(entry.Key)) if err != nil || !identifier(key) { return nil, fmt.Errorf("key %q is not an identifier", nodeText(entry.Key)) } scalar, err := nodeScalar(unwrapNode(entry.Value)) if err != nil { return nil, fmt.Errorf("%s: %w", key, err) } out[key] = scalar } return out, nil } func compileOptions(node ast.Node, field *FormField) error { switch n := node.(type) { case *ast.StringNode: if !identifier(n.Value) { return fmt.Errorf("method %q is not an identifier", n.Value) } field.optionsMethod = n.Value return nil case *ast.MappingNode: options := make([]FormOption, 0, len(n.Values)) for _, entry := range n.Values { value, err := nodeScalar(unwrapNode(entry.Key)) if err != nil { return fmt.Errorf("value: %w", err) } label, err := nodeString(unwrapNode(entry.Value)) if err != nil { return fmt.Errorf("label: %w", err) } options = append(options, FormOption{Value: value, Label: label}) } field.Options = options return nil default: return fmt.Errorf("must be a map or a method name") } } func sequenceValues(n *ast.SequenceNode) []ast.Node { if len(n.Values) > 0 { return n.Values } out := make([]ast.Node, 0, len(n.Entries)) for _, entry := range n.Entries { if entry != nil && entry.Value != nil { out = append(out, entry.Value) } } return out } func unwrapNode(node ast.Node) ast.Node { for node != nil { switch n := node.(type) { case *ast.MappingKeyNode: node = n.Value case *ast.TagNode: node = n.Value case *ast.AnchorNode: node = n.Value default: return node } } return nil } func nodeString(node ast.Node) (string, error) { switch n := unwrapNode(node).(type) { case *ast.StringNode: return n.Value, nil case nil: return "", fmt.Errorf("missing string") default: return "", fmt.Errorf("want a string, got %T", n) } } func nodeBool(node ast.Node) (bool, error) { n, ok := unwrapNode(node).(*ast.BoolNode) if !ok { return false, fmt.Errorf("want a boolean") } return n.Value, nil } func nodeScalar(node ast.Node) (jsonScalar, error) { switch n := unwrapNode(node).(type) { case *ast.StringNode: raw, err := json.Marshal(n.Value) return jsonScalar{raw: raw}, err case *ast.BoolNode: if n.Value { return jsonScalar{raw: []byte("true")}, nil } return jsonScalar{raw: []byte("false")}, nil case *ast.IntegerNode: return integerScalar(n.Value) case *ast.FloatNode: if n.Value == float64(int64(n.Value)) { return jsonScalar{raw: []byte(strconv.FormatInt(int64(n.Value), 10))}, nil } raw, err := json.Marshal(n.Value) return jsonScalar{raw: raw}, err default: return jsonScalar{}, fmt.Errorf("want a scalar, got %T", n) } } func integerScalar(v any) (jsonScalar, error) { switch n := v.(type) { case int64: return jsonScalar{raw: []byte(strconv.FormatInt(n, 10))}, nil case uint64: return jsonScalar{raw: []byte(strconv.FormatUint(n, 10))}, nil case int: return jsonScalar{raw: []byte(strconv.Itoa(n))}, nil default: return jsonScalar{}, fmt.Errorf("want an integer, got %T", v) } } func nodeText(node ast.Node) string { if node == nil { return "" } if token := node.GetToken(); token != nil { return token.Value } return "" } func normalizeYAMLError(err error) error { if err == nil { return nil } msg := err.Error() if strings.Contains(msg, "already defined") { return fmt.Errorf("duplicate field: %w", err) } return err } func compileFormIfPresent(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) { dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/") if dir == "." || strings.HasPrefix(dir, "..") { return nil, bootErr(pluginID, ctl.ID(), ctl.ConfigDir(), fmt.Errorf("config directory escapes the plugin")) } cfgPath := path.Join(dir, "config_form.yaml") if _, err := fs.Stat(fsys, cfgPath); err != nil { if errors.Is(err, fs.ErrNotExist) { return nil, nil } return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) } return CompileForm(pluginID, ctl, fsys) }