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:
Jakub Zych
2026-09-24 17:17:19 +02:00
parent 01d3871510
commit dfa00f7e3a
13 changed files with 1178 additions and 13 deletions

236
cabana/auth.go Normal file
View File

@@ -0,0 +1,236 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/bouncer"
"gorm.io/gorm"
)
const (
msgInvalidCredentials = "Invalid credentials"
msgUnauthenticated = "Unauthenticated"
msgForbidden = "Forbidden"
msgNotFound = "Not found"
msgServerError = "Server error"
)
// BackendUserRole is the Winter backend_user_roles row.
type BackendUserRole struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Code string `gorm:"column:code"`
Description string `gorm:"column:description"`
Permissions string `gorm:"column:permissions"`
IsSystem bool `gorm:"column:is_system"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (BackendUserRole) TableName() string { return "backend_user_roles" }
// BackendUser is the Winter backend_users row. It is not a frontend user.
type BackendUser struct {
ID uint `gorm:"column:id;primaryKey"`
FirstName string `gorm:"column:first_name"`
LastName string `gorm:"column:last_name"`
Login string `gorm:"column:login"`
Email string `gorm:"column:email"`
Password string `gorm:"column:password"`
IsActivated bool `gorm:"column:is_activated"`
IsSuperuser bool `gorm:"column:is_superuser"`
RoleID *uint `gorm:"column:role_id"`
LastLogin *time.Time `gorm:"column:last_login"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
Role BackendUserRole
}
func (BackendUser) TableName() string { return "backend_users" }
// BackendUsers loads activated backend principals. It never reads frontend users.
type BackendUsers struct {
DB *gorm.DB
}
func (p BackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
if p.DB == nil || id == 0 {
return nil, nil
}
var user BackendUser
err := p.DB.WithContext(ctx).Preload("Role").First(&user, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
if !user.IsActivated {
return nil, nil
}
return principalFrom(user), nil
}
func principalFrom(user BackendUser) *bouncer.Principal {
return &bouncer.Principal{
ID: user.ID,
IsSuperuser: user.IsSuperuser,
PermissionGrants: parseGrants(user.Role.Permissions),
}
}
func parseGrants(raw string) map[string]bool {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "{}" || raw == "null" {
return nil
}
var decoded map[string]any
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
return nil
}
out := make(map[string]bool, len(decoded))
for code, value := range decoded {
if truthyGrant(value) {
out[code] = true
}
}
if len(out) == 0 {
return nil
}
return out
}
func truthyGrant(value any) bool {
switch v := value.(type) {
case bool:
return v
case float64:
return v == 1
case string:
return v == "1" || strings.EqualFold(v, "true")
case json.Number:
return v.String() == "1"
default:
return false
}
}
type loginBody struct {
Login string `json:"login"`
Email string `json:"email"`
Password string `json:"password"`
}
func (s *service) login(w http.ResponseWriter, r *http.Request) {
var body loginBody
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
if err := dec.Decode(&body); err != nil {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials)
return
}
identifier := strings.TrimSpace(body.Login)
if identifier == "" {
identifier = strings.TrimSpace(body.Email)
}
if identifier == "" || body.Password == "" {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials)
return
}
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
user, found, err := findBackendLogin(db.WithContext(r.Context()), identifier)
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
hash := user.Password
if !found {
hash = dummyPasswordHash
}
if !found || !user.IsActivated || !bouncer.CheckPassword(hash, body.Password) {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials)
return
}
token, _, err := bouncer.MintAudience(s.secret, uitoa(user.ID), s.issuer, s.ttl, bouncer.AudienceBackend)
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
WriteData(w, http.StatusOK, map[string]string{
"access_token": token,
"token_type": "bearer",
}, map[string]any{})
}
func findBackendLogin(db *gorm.DB, identifier string) (BackendUser, bool, error) {
email := strings.ToLower(identifier)
var user BackendUser
err := db.Preload("Role").Where("login = ? OR lower(email) = ?", identifier, email).First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return BackendUser{}, false, nil
}
if err != nil {
return BackendUser{}, false, err
}
return user, true, nil
}
func (s *service) db() (*gorm.DB, error) {
if s == nil || s.app == nil {
return nil, errors.New("cabana: database is not configured")
}
db, ok := s.app.Lookup[*gorm.DB]()
if !ok || db == nil {
return nil, errors.New("cabana: database is not configured")
}
return db, nil
}
func adminSecret(app *backpack.App) (string, error) {
secret := ""
if app != nil && app.Config != nil {
secret = strings.TrimSpace(app.Config.String("admin.jwt.secret"))
}
if secret == "" {
return "", errors.New("cabana: admin.jwt.secret is empty (set SUMMER_ADMIN__JWT__SECRET)")
}
return secret, nil
}
func adminTTL(app *backpack.App) time.Duration {
minutes := 60
if app != nil && app.Config != nil && app.Config.Int("admin.jwt.ttl") > 0 {
minutes = app.Config.Int("admin.jwt.ttl")
}
return time.Duration(minutes) * time.Minute
}
func adminIssuer(app *backpack.App) string {
base := ""
if app != nil && app.Config != nil {
base = strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/")
}
if base == "" {
return "/_admin/api/v1/auth/login"
}
return base + "/_admin/api/v1/auth/login"
}
// dummyPasswordHash keeps a missing-user login on the bcrypt path.
var dummyPasswordHash = func() string {
hash, err := bouncer.HashPassword(10, "cabana-invalid-credentials")
if err != nil {
return ""
}
return hash
}()

117
cabana/contracts.go Normal file
View File

@@ -0,0 +1,117 @@
package cabana
import (
"encoding/json"
"net/http"
"strings"
"git.golem15.com/golem15/summercms/bouncer"
"git.golem15.com/golem15/summercms/pact"
)
// Option is a dropdown choice shared with later schema plans.
type Option = pact.Option
// ListColumn is one compiled columns.yaml entry, in file order.
type ListColumn struct {
Key string `json:"key"`
Label string `json:"label"`
Searchable bool `json:"searchable"`
Sortable bool `json:"sortable"`
Type string `json:"type,omitempty"`
}
// ListSchema is the boot-compiled list contract for one controller.
type ListSchema struct {
Title string `json:"title,omitempty"`
RecordsPerPage int `json:"recordsPerPage"`
ShowSearch bool `json:"showSearch"`
Columns []ListColumn `json:"columns"`
}
// CompiledController is one admin controller after YAML compilation.
type CompiledController struct {
PluginID string
Controller pact.AdminController
List *ListSchema
}
// Registry is the immutable controller map keyed by controller ID.
type Registry struct {
byID map[string]*CompiledController
}
// Get returns the compiled controller for a D-09 id (vendor.plugin.controller).
func (r *Registry) Get(id string) (*CompiledController, bool) {
if r == nil {
return nil, false
}
cc, ok := r.byID[id]
return cc, ok && cc != nil
}
// Allows reports whether principal satisfies every required permission code.
// A nil principal fails. Superusers pass. An empty requirement list allows
// any authenticated principal. Grants ending in ".*" match by prefix.
func Allows(principal *bouncer.Principal, required []string) bool {
if principal == nil {
return false
}
if principal.IsSuperuser || len(required) == 0 {
return true
}
for _, code := range required {
if !granted(principal.PermissionGrants, code) {
return false
}
}
return true
}
func granted(grants map[string]bool, code string) bool {
if grants[code] {
return true
}
for key, on := range grants {
if !on || !strings.HasSuffix(key, ".*") {
continue
}
prefix := strings.TrimSuffix(key, "*")
if strings.HasPrefix(code, prefix) {
return true
}
}
return false
}
func requiredOf(ctl pact.AdminController) []string {
if p, ok := ctl.(pact.AdminPermissioned); ok && p != nil {
return p.RequiredPermissions()
}
return nil
}
// WriteData writes a D-10 success envelope.
func WriteData(w http.ResponseWriter, status int, data, meta any) {
if meta == nil {
meta = map[string]any{}
}
writeJSON(w, status, map[string]any{"data": data, "meta": meta})
}
// WriteError writes a D-10 error envelope. details is always an object.
func WriteError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, map[string]any{
"error": map[string]any{
"code": code,
"message": message,
"details": map[string]any{},
},
})
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}

278
cabana/http.go Normal file
View File

@@ -0,0 +1,278 @@
package cabana
import (
"context"
"errors"
"net/http"
"reflect"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/bouncer"
"git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/party"
"gorm.io/gorm"
)
// Routes is the raw admin API mounted by surf.BuildRouter.
type Routes struct {
Middleware pact.Middleware
Mount func(r pact.Router)
}
type service struct {
app *backpack.App
reg *Registry
secret string
ttl time.Duration
issuer string
}
// Activate compiles admin controllers and, when any exist, requires
// admin.jwt.secret. No controllers means no admin routes and no secret check.
func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
items, err := collectControllers(plugins)
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, nil
}
secret, err := adminSecret(app)
if err != nil {
return nil, err
}
reg, err := compileRegistry(items)
if err != nil {
return nil, err
}
if app == nil {
return nil, errors.New("cabana: app is nil")
}
guards, ok := app.Lookup[*bouncer.Registry]()
if !ok || guards == nil {
guards = bouncer.NewRegistry()
if err := app.Publish(guards); err != nil {
return nil, err
}
}
var bl bouncer.BlacklistStore
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
bl = store
}
guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app}, bl, writeUnauthenticated)
if _, err := guards.Middleware("backend"); err != nil {
if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
return nil, err
}
}
mw, err := guards.Middleware("backend")
if err != nil {
return nil, err
}
svc := &service{
app: app,
reg: reg,
secret: secret,
ttl: adminTTL(app),
issuer: adminIssuer(app),
}
return &Routes{Middleware: mw, Mount: svc.mount}, nil
}
func writeUnauthenticated(w http.ResponseWriter, _ error) {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
}
type lazyBackendUsers struct {
app *backpack.App
}
func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
if p.app == nil {
return nil, errors.New("cabana: database is not configured")
}
db, ok := p.app.Lookup[*gorm.DB]()
if !ok || db == nil {
return nil, errors.New("cabana: database is not configured")
}
return (BackendUsers{DB: db}).FindByID(ctx, id)
}
func (s *service) mount(r pact.Router) {
r.GroupRaw("/_admin/api/v1/auth", nil, func(g pact.Router) {
g.Post("/login", s.login)
})
r.GroupRaw("/_admin/api/v1", []string{"backend"}, func(g pact.Router) {
g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema)
constrainController(g)
g.Get("/{vendor}/{plugin}/{controller}", s.list)
constrainController(g)
})
}
func constrainController(g pact.Router) {
g.Where("vendor", "[A-Za-z0-9_-]+")
g.Where("plugin", "[A-Za-z0-9_-]+")
g.Where("controller", "[A-Za-z0-9_-]+")
}
func (s *service) listSchema(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
cols := cc.List.Columns
if cols == nil {
cols = []ListColumn{}
}
WriteData(w, http.StatusOK, &ListSchema{
Title: cc.List.Title,
RecordsPerPage: cc.List.RecordsPerPage,
ShowSearch: cc.List.ShowSearch,
Columns: cols,
}, map[string]any{})
})
}
func (s *service) list(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
rows, total, err := queryList(r.Context(), db, cc)
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
data := make([]map[string]any, 0, len(rows))
for _, row := range rows {
data = append(data, projectRow(row, cc.List.Columns))
}
per := cc.List.RecordsPerPage
if per < 1 {
per = 20
}
last := int((total + int64(per) - 1) / int64(per))
if last < 1 {
last = 1
}
WriteData(w, http.StatusOK, data, map[string]any{
"page": 1,
"per_page": per,
"total": total,
"last_page": last,
})
})
}
// protect runs after the backend guard. Controller lookup precedes permission
// evaluation, and schema/SQL run only inside fn.
func (s *service) protect(w http.ResponseWriter, r *http.Request, fn func(*CompiledController)) {
id := r.PathValue("vendor") + "." + r.PathValue("plugin") + "." + r.PathValue("controller")
cc, ok := s.reg.Get(id)
if !ok {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
principal, _ := bouncer.User(r.Context())
if principal == nil {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
if !Allows(principal, requiredOf(cc.Controller)) {
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
}
fn(cc)
}
func queryList(ctx context.Context, db *gorm.DB, cc *CompiledController) ([]any, int64, error) {
src, ok := cc.Controller.(pact.AdminRecordSource)
if !ok || src == nil {
return nil, 0, errors.New("cabana: admin controller has no record source")
}
model := src.NewRecord()
mt := reflect.TypeOf(model)
if mt == nil || mt.Kind() != reflect.Pointer {
return nil, 0, errors.New("cabana: admin model must be a pointer")
}
q := db.WithContext(ctx).Model(model)
if ext, ok := cc.Controller.(pact.ListExtendQuery); ok && ext != nil {
if next := ext.ListExtendQuery(ctx, q); next != nil {
q = next
}
}
var total int64
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
return nil, 0, err
}
per := 20
if cc.List != nil && cc.List.RecordsPerPage > 0 {
per = cc.List.RecordsPerPage
}
slice := reflect.New(reflect.SliceOf(mt.Elem()))
if err := q.Session(&gorm.Session{}).Limit(per).Find(slice.Interface()).Error; err != nil {
return nil, 0, err
}
values := slice.Elem()
out := make([]any, values.Len())
for i := 0; i < values.Len(); i++ {
out[i] = values.Index(i).Addr().Interface()
}
return out, total, nil
}
func projectRow(row any, cols []ListColumn) map[string]any {
v := reflect.ValueOf(row)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return map[string]any{}
}
v = v.Elem()
}
out := make(map[string]any, len(cols)+1)
if id := fieldByColumn(v, "id"); id.IsValid() && id.CanInterface() {
out["id"] = id.Interface()
}
for _, col := range cols {
field := fieldByColumn(v, col.Key)
if !field.IsValid() || !field.CanInterface() {
continue
}
out[col.Key] = field.Interface()
}
return out
}
func fieldByColumn(v reflect.Value, column string) reflect.Value {
if v.Kind() != reflect.Struct {
return reflect.Value{}
}
t := v.Type()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath != "" {
continue
}
if gormColumn(field) == column || strings.EqualFold(field.Name, column) {
return v.Field(i)
}
}
return reflect.Value{}
}
func gormColumn(field reflect.StructField) string {
for _, part := range strings.Split(field.Tag.Get("gorm"), ";") {
part = strings.TrimSpace(part)
if name, ok := strings.CutPrefix(part, "column:"); ok {
return name
}
}
return ""
}
func uitoa(id uint) string {
return strconv.FormatUint(uint64(id), 10)
}

59
cabana/registry.go Normal file
View File

@@ -0,0 +1,59 @@
package cabana
import (
"fmt"
"strings"
"git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/party"
)
type controllerRef struct {
plugin party.Plugin
ctl pact.AdminController
}
func collectControllers(plugins []party.Plugin) ([]controllerRef, error) {
var out []controllerRef
for _, p := range plugins {
src, ok := p.(pact.HasAdminControllers)
if !ok || p == nil {
continue
}
for _, ctl := range src.AdminControllers() {
if ctl == nil {
continue
}
id := ctl.ID()
if id != p.ID() && !strings.HasPrefix(id, p.ID()+".") {
return nil, fmt.Errorf("cabana: controller %s is not owned by plugin %s", id, p.ID())
}
out = append(out, controllerRef{plugin: p, ctl: ctl})
}
}
return out, nil
}
func compileRegistry(items []controllerRef) (*Registry, error) {
byID := make(map[string]*CompiledController, len(items))
for _, item := range items {
id := item.ctl.ID()
if _, exists := byID[id]; exists {
return nil, fmt.Errorf("cabana: duplicate admin controller %s", id)
}
assets, ok := item.plugin.(pact.AdminAssets)
if !ok || assets == nil || assets.AdminFS() == nil {
return nil, fmt.Errorf("cabana: plugin %s has admin controllers but no AdminFS", item.plugin.ID())
}
list, err := compileList(item.plugin.ID(), item.ctl, assets.AdminFS())
if err != nil {
return nil, err
}
byID[id] = &CompiledController{
PluginID: item.plugin.ID(),
Controller: item.ctl,
List: list,
}
}
return &Registry{byID: byID}, nil
}

161
cabana/schema.go Normal file
View 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)
}