package cabana import ( "bytes" "fmt" "io/fs" "path" "strings" "git.golem15.com/golem15/summercms/pact" "github.com/goccy/go-yaml" ) type listDocument struct { List string `yaml:"list"` ModelClass string `yaml:"modelClass"` Title string `yaml:"title"` RecordURL string `yaml:"recordUrl"` NoRecordsMessage string `yaml:"noRecordsMessage"` RecordsPerPage int `yaml:"recordsPerPage"` ShowCheckboxes bool `yaml:"showCheckboxes"` ShowSearch bool `yaml:"showSearch"` Toolbar *struct { Buttons string `yaml:"buttons"` Search *struct { Prompt string `yaml:"prompt"` } `yaml:"search"` } `yaml:"toolbar"` } type columnsDocument struct { Columns yaml.MapSlice `yaml:"columns"` } type columnDocument struct { Label string `yaml:"label"` Searchable bool `yaml:"searchable"` Sortable bool `yaml:"sortable"` Type string `yaml:"type"` Relation string `yaml:"relation"` Select string `yaml:"select"` } func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSchema, 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_list.yaml") raw, err := readAsset(fsys, cfgPath) if err != nil { return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) } var doc listDocument if err := decodeStrict(raw, &doc); err != nil { return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) } if strings.TrimSpace(doc.List) == "" { return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("list file is empty")) } colPath, err := assetPath(pluginID, doc.List) if err != nil { return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) } colRaw, err := readAsset(fsys, colPath) if err != nil { return nil, bootErr(pluginID, ctl.ID(), colPath, err) } var cols columnsDocument if err := decodeStrict(colRaw, &cols); err != nil { return nil, bootErr(pluginID, ctl.ID(), colPath, err) } compiled := make([]ListColumn, 0, len(cols.Columns)) seen := map[string]struct{}{} for _, item := range cols.Columns { key, ok := item.Key.(string) if !ok || !identifier(key) { return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column key %v is not an identifier", item.Key)) } if _, dup := seen[key]; dup { return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("duplicate column %s", key)) } seen[key] = struct{}{} encoded, err := yaml.Marshal(item.Value) if err != nil { return nil, bootErr(pluginID, ctl.ID(), colPath, err) } var spec columnDocument if err := decodeStrict(encoded, &spec); err != nil { return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column %s: %w", key, err)) } compiled = append(compiled, ListColumn{ Key: key, Label: spec.Label, Searchable: spec.Searchable, Sortable: spec.Sortable, Type: spec.Type, }) } per := doc.RecordsPerPage if per < 1 { per = 20 } showSearch := doc.ShowSearch if doc.Toolbar != nil && doc.Toolbar.Search != nil { showSearch = true } return &ListSchema{ Title: doc.Title, RecordsPerPage: per, ShowSearch: showSearch, Columns: compiled, }, nil } func decodeStrict(raw []byte, dest any) error { dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField()) if err := dec.Decode(dest); err != nil { return err } return nil } func readAsset(fsys fs.FS, name string) ([]byte, error) { name = path.Clean(name) if name == "." || strings.HasPrefix(name, "..") || strings.Contains(name, "..") { return nil, fmt.Errorf("path escapes the plugin") } return fs.ReadFile(fsys, name) } func assetPath(pluginID, ref string) (string, error) { ref = strings.TrimSpace(ref) ref = strings.TrimPrefix(ref, "~/") prefix := "plugins/" + strings.ReplaceAll(pluginID, ".", "/") + "/" ref = strings.TrimPrefix(ref, prefix) ref = path.Clean(ref) if ref == "." || strings.HasPrefix(ref, "..") || strings.Contains(ref, "..") { return "", fmt.Errorf("list path escapes the plugin") } return ref, nil } func identifier(s string) bool { if s == "" { return false } for i, r := range s { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_': case i > 0 && r >= '0' && r <= '9': default: return false } } return true } func bootErr(pluginID, controllerID, file string, err error) error { return fmt.Errorf("cabana: admin schema %s/%s/%s: %w", pluginID, controllerID, file, err) }