feat(09-01): implement separate-admin genre list tracer
- 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
This commit is contained in:
161
cabana/schema.go
Normal file
161
cabana/schema.go
Normal file
@@ -0,0 +1,161 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user