package cabana import ( "context" "database/sql/driver" "encoding/json" "fmt" "math" "reflect" "strconv" "strings" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/seaglass" "git.golem15.com/golem15/summercms/modules/towel" "github.com/goccy/go-yaml/ast" ) // repeaterType is the core list-of-rows field type (D-07). const repeaterType = "repeater" // The phrase keys of the repeater's row count messages (backend::lang). const ( msgRepeaterMaxItems = "backend::lang.repeater.max_items" msgRepeaterMinItems = "backend::lang.repeater.min_items" ) // repeaterKeys are the fields.yaml keys a repeater field accepts. var repeaterKeys = map[string]bool{ "label": true, "comment": true, "span": true, "type": true, "required": true, "tab": true, "context": true, "prompt": true, "maxItems": true, "minItems": true, "titleFrom": true, "form": true, } // repeaterOnlyKeys are valid on type repeater only. var repeaterOnlyKeys = []string{"form", "maxItems", "minItems", "titleFrom"} // repeaterNestedTypes are the field types a repeater row may hold. var repeaterNestedTypes = map[string]bool{ "text": true, "textarea": true, "number": true, "checkbox": true, "switch": true, "dropdown": true, richEditorType: true, } // compileRepeater decodes a `type: repeater` field: its nested form.fields // (compiled with the rules of top-level fields, restricted to // repeaterNestedTypes), prompt, maxItems, minItems and titleFrom. WinterCMS's // groups and style are refused loudly on any field, the repeater-only keys on // any other type. The span is always full. func compileRepeater(name, typ string, values map[string]ast.Node, field *FormField) error { if _, ok := values["groups"]; ok { return fmt.Errorf("repeater groups are not supported") } if _, ok := values["style"]; ok { return fmt.Errorf("repeater style is not supported") } if typ != repeaterType { for _, key := range repeaterOnlyKeys { if _, ok := values[key]; ok { return fmt.Errorf("%s is only valid on type: repeater", key) } } return nil } for key := range values { if !repeaterKeys[key] { return fmt.Errorf("%s is not valid on type: repeater", key) } } field.Span = "full" if node, ok := values["prompt"]; ok { prompt, err := nodeString(node) if err != nil { return fmt.Errorf("prompt: %w", err) } field.Prompt = prompt } formNode, ok := values["form"].(*ast.MappingNode) if !ok { return fmt.Errorf("a repeater needs form: with fields") } var fieldsNode ast.Node for _, entry := range formNode.Values { key, err := nodeString(unwrapNode(entry.Key)) if err != nil { return fmt.Errorf("form: %w", err) } switch key { case "fields": fieldsNode = unwrapNode(entry.Value) case "groups": return fmt.Errorf("repeater groups are not supported") default: return fmt.Errorf("form: unknown field %s (a repeater form takes fields only)", key) } } if fieldsNode == nil { return fmt.Errorf("a repeater needs form: with fields") } nested, err := decodeFieldMapping(fieldsNode, nil, nil) if err != nil { return fmt.Errorf("repeater %s: %w", name, err) } if len(nested) == 0 { return fmt.Errorf("repeater %s: form.fields is empty", name) } for _, inner := range nested { if !repeaterNestedTypes[inner.Type] { return fmt.Errorf("repeater %s: field %s: type %s is not supported in a repeater (want text, textarea, number, checkbox, switch, dropdown or richeditor)", name, inner.Name, inner.Type) } if inner.Tab != "" || inner.Context != nil || inner.Preset != nil { return fmt.Errorf("repeater %s: field %s: tab, context and preset are not supported in a repeater row", name, inner.Name) } } if err := checkDependsOn(nested, name); err != nil { return err } field.Form = &RepeaterForm{Fields: nested} for _, key := range []string{"maxItems", "minItems"} { node, ok := values[key] if !ok { continue } n, err := nodeNonNegativeInt(node) if err != nil { return fmt.Errorf("%s: %w", key, err) } if key == "maxItems" { field.MaxItems = &n } else { field.MinItems = &n } } if field.MinItems != nil && field.MaxItems != nil && *field.MinItems > *field.MaxItems { return fmt.Errorf("minItems %d is greater than maxItems %d", *field.MinItems, *field.MaxItems) } if node, ok := values["titleFrom"]; ok { title, err := nodeString(node) if err != nil { return fmt.Errorf("titleFrom: %w", err) } found := false for _, inner := range nested { found = found || inner.Name == title } if !found { return fmt.Errorf("titleFrom %s is not a field of the repeater form", title) } field.TitleFrom = title } return nil } func nodeNonNegativeInt(node ast.Node) (int, error) { n, ok := unwrapNode(node).(*ast.IntegerNode) if !ok { return 0, fmt.Errorf("want a non-negative integer") } var v int64 switch t := n.Value.(type) { case int64: v = t case uint64: if t > math.MaxInt32 { return 0, fmt.Errorf("want a non-negative integer") } v = int64(t) case int: v = int64(t) default: return 0, fmt.Errorf("want a non-negative integer") } if v < 0 || v > math.MaxInt32 { return 0, fmt.Errorf("want a non-negative integer") } return int(v), nil } // repeaterNames is the set of the controller form's repeater field names. func repeaterNames(cc *CompiledController) map[string]bool { if cc == nil || cc.Form == nil { return nil } var out map[string]bool for _, field := range cc.Form.Fields { if field.Type == repeaterType { if out == nil { out = map[string]bool{} } out[field.Name] = true } } return out } // listValue reports whether v is a JSON list. func listValue(v any) bool { _, ok := v.([]any) return ok } // liftRepeaterValues decodes every submitted repeater value of the form in // place, before the scalar projection: null becomes an empty list; a list // must hold objects, and each row keeps only the nested field names, with its // scalars coerced per nested type (number to a JSON number, checkbox and // switch to a boolean, the rest to a string or null). A required nested value // missing from a row is a 422 under field.index.nested; a row count above // maxItems or below minItems is a 422 on the field; any other shape is a 422 // on the field. A required repeater needs at least one row when it is // submitted, and on create also when it is not. func liftRepeaterValues(ctx context.Context, cc *CompiledController, body map[string]any, op string, tr *phrasebook.Translator) error { if cc == nil || cc.Form == nil || body == nil { return nil } ctx = towel.WithLocale(ctx, schemaLocale(ctx, tr)) details := map[string]any{} for _, field := range cc.Form.Fields { if field.Type != repeaterType || !contextAllows(cc, field.Name, op) { continue } raw, present := body[field.Name] if !present { if field.Required && op == "create" { details[field.Name] = []string{"The " + field.Name + " field is required."} } continue } var items []any switch v := raw.(type) { case nil: items = []any{} case []any: items = v default: details[field.Name] = []string{"The " + field.Name + " field must be a list of rows."} continue } rows := make([]any, 0, len(items)) shapeOK := true for i, item := range items { row, ok := item.(map[string]any) if !ok { shapeOK = false break } clean, errs := decodeRepeaterRow(field, i, row) for key, msg := range errs { details[key] = []string{msg} } rows = append(rows, clean) } if !shapeOK { details[field.Name] = []string{"The " + field.Name + " field must be a list of rows."} continue } switch { case field.Required && len(rows) == 0: details[field.Name] = []string{"The " + field.Name + " field is required."} case field.MaxItems != nil && len(rows) > *field.MaxItems: details[field.Name] = []string{trParams(ctx, tr, msgRepeaterMaxItems, map[string]string{"max": strconv.Itoa(*field.MaxItems)})} case field.MinItems != nil && len(rows) < *field.MinItems: details[field.Name] = []string{trParams(ctx, tr, msgRepeaterMinItems, map[string]string{"min": strconv.Itoa(*field.MinItems)})} } body[field.Name] = rows } if len(details) > 0 { return &ValidationError{Details: details} } return nil } // trParams translates key with params, or keeps the key without a // translator. func trParams(ctx context.Context, tr *phrasebook.Translator, key string, params map[string]string) string { if tr == nil { return key } return tr.Get(ctx, key, params) } // decodeRepeaterRow keeps the nested fields of one submitted row, coerced per // type, and reports the row's errors keyed field.index.nested. func decodeRepeaterRow(field FormField, index int, row map[string]any) (map[string]any, map[string]string) { out := make(map[string]any, len(field.Form.Fields)) errs := map[string]string{} for _, inner := range field.Form.Fields { path := fmt.Sprintf("%s.%d.%s", field.Name, index, inner.Name) value, err := coerceRowValue(inner.Type, row[inner.Name]) if err != nil { errs[path] = "The " + path + " field " + err.Error() + "." continue } if inner.Required && blankValue(value) { errs[path] = "The " + path + " field is required." } out[inner.Name] = value } return out, errs } // coerceRowValue turns a submitted nested value into what a row stores for // typ. func coerceRowValue(typ string, v any) (any, error) { switch typ { case "checkbox", "switch": switch t := v.(type) { case nil: return false, nil case bool: return t, nil case string: switch strings.ToLower(strings.TrimSpace(t)) { case "1", "true", "on", "yes": return true, nil case "", "0", "false", "off", "no": return false, nil } case json.Number: return t.String() != "0", nil case float64: return t != 0, nil } return nil, fmt.Errorf("must be true or false") case "number": switch t := v.(type) { case nil: return nil, nil case json.Number: return t, nil case float64: return json.Number(strconv.FormatFloat(t, 'f', -1, 64)), nil case int: return json.Number(strconv.Itoa(t)), nil case string: s := strings.TrimSpace(t) if s == "" { return nil, nil } if _, err := strconv.ParseFloat(s, 64); err == nil { return json.Number(s), nil } } return nil, fmt.Errorf("must be a number") default: switch t := v.(type) { case nil: return nil, nil case string: return t, nil case json.Number: return t.String(), nil case float64: return strconv.FormatFloat(t, 'f', -1, 64), nil case bool: return strconv.FormatBool(t), nil } return nil, fmt.Errorf("must be text") } } func blankValue(v any) bool { switch t := v.(type) { case nil: return true case string: return strings.TrimSpace(t) == "" } return false } // repeaterRows is the projected rows of a repeater field, or false. func repeaterRows(projected map[string]any, key string) ([]any, bool) { v, ok := projected[key] if !ok { return nil, false } rows, ok := v.([]any) return rows, ok } // sanitizeRepeaterRichEditors runs every nested richeditor value of the // projected repeater rows through seaglass, so no row stores unsanitized // HTML, and reports removals under field.index.nested. func sanitizeRepeaterRichEditors(cc *CompiledController, projected map[string]any) []pluginNotice { if cc == nil || cc.Form == nil { return nil } var notices []pluginNotice for _, field := range cc.Form.Fields { if field.Type != repeaterType || field.Form == nil { continue } rows, ok := repeaterRows(projected, fillKeyOf(cc, field.Name)) if !ok { continue } for i, item := range rows { row, ok := item.(map[string]any) if !ok { continue } for _, inner := range field.Form.Fields { if inner.Type != richEditorType { continue } text, ok := row[inner.Name].(string) if !ok { continue } clean, report := seaglass.SanitizeReport(text) row[inner.Name] = clean path := fmt.Sprintf("%s.%d.%s", field.Name, i, inner.Name) for _, notice := range richEditorNotices(report) { notices = append(notices, pluginNotice{field: path, notice: notice}) } } } } return notices } // hydrateRepeaterFields replaces the stored value of every repeater field in // a record response with its list of rows: a lagoon.Jsonable column (or any // driver.Valuer) is decoded from its JSON text, any other value through a // JSON round trip. A missing or null value is an empty list. func hydrateRepeaterFields(cc *CompiledController, data map[string]any) { if cc == nil || cc.Form == nil || data == nil { return } for _, field := range cc.Form.Fields { if field.Type != repeaterType { continue } value, ok := data[field.Name] if !ok { continue } data[field.Name] = storedRows(value) } } func storedRows(value any) []any { rows := []any{} raw, ok := storedJSON(value) if !ok { return rows } var decoded []any if err := json.Unmarshal(raw, &decoded); err != nil || decoded == nil { return rows } return decoded } // storedJSON is the JSON text of a stored repeater value: a text column's // string, a driver.Valuer's text (lagoon.Jsonable), or any other value // marshaled. It is false for a missing or null value. func storedJSON(value any) ([]byte, bool) { switch t := value.(type) { case nil: return nil, false case string: return []byte(t), true case *string: if t == nil { return nil, false } return []byte(*t), true case driver.Valuer: v, err := t.Value() if err != nil || v == nil { return nil, false } switch text := v.(type) { case []byte: return text, true case string: return []byte(text), true } return nil, false } rv := reflect.ValueOf(value) if rv.Kind() == reflect.Pointer && rv.IsNil() { return nil, false } b, err := json.Marshal(value) return b, err == nil }