Files
summercms/cabana/filter_schema.go
Jakub Zych d3a93073c9 feat(09-04): compile switch, date-range, and scope filters
- Filters keep typed values and only registered scope names
- Raw conditions and arbitrary methods fail activation
- Request localization copies labels and leaves identifiers unchanged
2026-09-24 18:53:52 +02:00

305 lines
8.0 KiB
Go

package cabana
import (
"fmt"
"io/fs"
"path"
"reflect"
"strings"
"time"
"git.golem15.com/golem15/summercms/pact"
"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)
}
for _, name := range provider.FilterScopes() {
if name == filter.Scope {
return nil
}
}
return fmt.Errorf("scope %s is not registered", filter.Scope)
}
return nil
}
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
}