- Audience-aware mint, verify, refresh, and backend guard keep frontend tokens compatible - Cabana mounts raw admin login, list schema, and record list behind admin.jwt.secret - Framework migration seeds Winter backend users and developer/publisher roles
162 lines
4.5 KiB
Go
162 lines
4.5 KiB
Go
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)
|
|
}
|