package cabana import ( "fmt" "io/fs" "net/http" "path" "reflect" "strings" "time" "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/towel" "github.com/goccy/go-yaml/ast" ) var filterScopeKeys = map[string]struct{}{ "label": {}, "type": {}, "column": {}, "options": {}, "modelClass": {}, "nameFrom": {}, "scope": {}, } type filterDocument struct { Scopes scopeMap `yaml:"scopes"` } type scopeMap struct { items []ListFilter } func compileFilters(pluginID string, ctl pact.AdminController, fsys fs.FS, dir, ref string) ([]ListFilter, error) { file, err := filterAssetPath(pluginID, dir, ref) if err != nil { return nil, bootErr(pluginID, ctl.ID(), "config_filter.yaml", err) } raw, err := readAsset(fsys, file) if err != nil { return nil, bootErr(pluginID, ctl.ID(), file, err) } var doc filterDocument if err := decodeStrict(raw, &doc); err != nil { return nil, bootErr(pluginID, ctl.ID(), file, err) } filters := doc.Scopes.items if filters == nil { filters = []ListFilter{} } provider := filterProvider(ctl) for _, filter := range filters { if err := validateFilter(ctl, provider, filter); err != nil { return nil, bootErr(pluginID, ctl.ID(), file, err) } } return filters, nil } func filterAssetPath(pluginID, dir, ref string) (string, error) { ref = strings.TrimSpace(ref) if ref == "" { return "", fmt.Errorf("filter file is empty") } if strings.Contains(ref, "..") { return "", fmt.Errorf("filter path escapes the plugin") } if strings.HasPrefix(ref, "~/") || strings.Contains(ref, "/") { return assetPath(pluginID, ref) } return path.Join(dir, ref), nil } func (m *scopeMap) UnmarshalYAML(node ast.Node) error { node = unwrapNode(node) if _, ok := node.(*ast.NullNode); ok || node == nil { m.items = []ListFilter{} return nil } mapping, ok := node.(*ast.MappingNode) if !ok { return fmt.Errorf("scopes must be a mapping") } items := make([]ListFilter, 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("filter name %q is not an identifier", nodeText(entry.Key)) } if _, dup := seen[name]; dup { return fmt.Errorf("duplicate filter %s", name) } seen[name] = struct{}{} filter, err := compileScopeNode(name, unwrapNode(entry.Value)) if err != nil { return fmt.Errorf("filter %s: %w", name, err) } items = append(items, filter) } m.items = items return nil } func compileScopeNode(name string, node ast.Node) (ListFilter, error) { mapping, ok := node.(*ast.MappingNode) if !ok { return ListFilter{}, 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 ListFilter{}, err } if key == "conditions" { return ListFilter{}, fmt.Errorf("conditions is not supported; use a column or a registered model scope") } if _, known := filterScopeKeys[key]; !known { return ListFilter{}, fmt.Errorf("unknown field %s", key) } if _, dup := values[key]; dup { return ListFilter{}, fmt.Errorf("duplicate field %s", key) } values[key] = unwrapNode(entry.Value) } filter := ListFilter{Name: name} var err error if node, ok := values["label"]; ok { filter.Label, err = nodeString(node) if err != nil { return ListFilter{}, fmt.Errorf("label: %w", err) } } if node, ok := values["type"]; ok { filter.Type, err = nodeString(node) if err != nil { return ListFilter{}, fmt.Errorf("type: %w", err) } } if node, ok := values["column"]; ok { filter.Column, err = nodeString(node) if err != nil || !identifier(filter.Column) { return ListFilter{}, fmt.Errorf("column %q is not an identifier", filter.Column) } } if node, ok := values["modelClass"]; ok { filter.ModelClass, err = nodeString(node) if err != nil || strings.TrimSpace(filter.ModelClass) == "" { return ListFilter{}, fmt.Errorf("modelClass is empty") } } if node, ok := values["nameFrom"]; ok { filter.NameFrom, err = nodeString(node) if err != nil || !identifier(filter.NameFrom) { return ListFilter{}, fmt.Errorf("nameFrom %q is not an identifier", filter.NameFrom) } } if node, ok := values["scope"]; ok { filter.Scope, err = nodeString(node) if err != nil || !identifier(filter.Scope) { return ListFilter{}, fmt.Errorf("scope %q is not an identifier", filter.Scope) } } if node, ok := values["options"]; ok { filter.Options, err = compileFilterOptions(node) if err != nil { return ListFilter{}, fmt.Errorf("options: %w", err) } } if filter.Type == "" && filter.Scope != "" { filter.Type = "scope" } switch filter.Type { case "switch": if filter.Column == "" { return ListFilter{}, fmt.Errorf("column is required") } if len(filter.Options) == 0 { on := jsonScalar{raw: []byte("true")} off := jsonScalar{raw: []byte("false")} filter.TrueValue = &on filter.FalseValue = &off } case "daterange": if filter.Column == "" { return ListFilter{}, fmt.Errorf("column is required") } case "scope": if filter.Scope == "" || filter.ModelClass == "" || filter.NameFrom == "" { return ListFilter{}, fmt.Errorf("scope, modelClass, and nameFrom are required") } default: if filter.Type == "" { return ListFilter{}, fmt.Errorf("type is required") } return ListFilter{}, fmt.Errorf("unsupported filter type %s", filter.Type) } return filter, nil } func compileFilterOptions(node ast.Node) ([]FormOption, error) { mapping, ok := node.(*ast.MappingNode) if !ok { return nil, fmt.Errorf("must be a map") } options := make([]FormOption, 0, len(mapping.Values)) for _, entry := range mapping.Values { value, err := nodeScalar(unwrapNode(entry.Key)) if err != nil { return nil, fmt.Errorf("value: %w", err) } label, err := nodeString(unwrapNode(entry.Value)) if err != nil { return nil, fmt.Errorf("label: %w", err) } options = append(options, FormOption{Value: value, Label: label}) } return options, nil } func validateFilter(ctl pact.AdminController, provider pact.FilterScope, filter ListFilter) error { switch filter.Type { case "switch", "daterange": if _, _, check := listModelContract(ctl); check { ft, ok := modelColumnType(ctl, filter.Column) if !ok { return fmt.Errorf("column %s is not on the model", filter.Column) } if filter.Type == "switch" && !isBoolType(ft) { return fmt.Errorf("column %s is not a boolean", filter.Column) } if filter.Type == "daterange" && !isTimeType(ft) { return fmt.Errorf("column %s is not a date", filter.Column) } } case "scope": if provider == nil { return fmt.Errorf("scope %s requires FilterScope", filter.Scope) } registered := false for _, name := range provider.FilterScopes() { if name == filter.Scope { registered = true break } } if !registered { return fmt.Errorf("scope %s is not registered", filter.Scope) } if _, ok := provider.(pact.FilterOptions); !ok { return fmt.Errorf("scope filter %s needs FilterOptions on the model to serve its choices (D-27)", filter.Name) } } return nil } // FilterOption is one model-backed filter choice (D-27). Values stay the // strings the model returns; they are sent back as filter[]. type FilterOption struct { Value string `json:"value"` Label string `json:"label"` } // filterOptions serves GET /{vendor}/{plugin}/{controller}/filters/{scope}/options // for a declared scope filter of the controller's list. {scope} is the filter // name (the filter[] key); the model receives the filter's scope method. func (s *service) filterOptions(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { name := r.PathValue("scope") var filter *ListFilter if cc.List != nil && identifier(name) { for i := range cc.List.Filters { if cc.List.Filters[i].Name == name && cc.List.Filters[i].Type == "scope" { filter = &cc.List.Filters[i] break } } } if filter == nil { writeNotFound(w, r) return } provider, ok := filterProvider(cc.Controller).(pact.FilterOptions) if !ok || provider == nil { writeNotFound(w, r) return } tr := s.translator() locale := schemaLocale(r.Context(), tr) ctx := towel.WithLocale(r.Context(), locale) provided := provider.FilterOptions(filter.Scope) out := make([]FilterOption, 0, len(provided)) for _, opt := range provided { out = append(out, FilterOption{Value: opt.Value, Label: translateKey(ctx, tr, opt.Label)}) } WriteData(w, http.StatusOK, out, map[string]any{"locale": locale}) }) } func filterProvider(ctl pact.AdminController) pact.FilterScope { src, ok := ctl.(pact.AdminRecordSource) if !ok || src == nil { return nil } rec := src.NewRecord() provider, ok := rec.(pact.FilterScope) if !ok || provider == nil { return nil } return provider } func modelColumnType(ctl pact.AdminController, column string) (reflect.Type, bool) { src, ok := ctl.(pact.AdminRecordSource) if !ok || src == nil || src.NewRecord() == nil { return nil, false } t := reflect.TypeOf(src.NewRecord()) for t != nil && t.Kind() == reflect.Pointer { t = t.Elem() } if t == nil || t.Kind() != reflect.Struct { return nil, false } for i := 0; i < t.NumField(); i++ { field := t.Field(i) if field.PkgPath != "" || isListRelation(field.Type) { continue } name := gormColumn(field) if name == "" { name = field.Name } if name == column { return field.Type, true } } return nil, false } func isTimeType(t reflect.Type) bool { if t == nil { return false } for t.Kind() == reflect.Pointer { t = t.Elem() } return t == reflect.TypeOf(time.Time{}) } func isBoolType(t reflect.Type) bool { if t == nil { return false } for t.Kind() == reflect.Pointer { t = t.Elem() } return t.Kind() == reflect.Bool }