feat(09-11): add permissioned admin metadata and settings
This commit is contained in:
@@ -16,6 +16,7 @@ type Principal struct {
|
||||
MustChangePassword bool
|
||||
PreferredLocale string
|
||||
TokensValidAfter time.Time
|
||||
Backend bool `json:"-"`
|
||||
IsSuperuser bool `json:"-"`
|
||||
PermissionGrants map[string]bool `json:"-"`
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ const (
|
||||
|
||||
// BackendUsers loads activated backend principals. It never reads frontend users.
|
||||
type BackendUsers struct {
|
||||
DB *gorm.DB
|
||||
DB *gorm.DB
|
||||
Registry *Registry
|
||||
}
|
||||
|
||||
func (p BackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
|
||||
@@ -48,12 +49,23 @@ func (p BackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal
|
||||
if !user.IsActivated {
|
||||
return nil, nil
|
||||
}
|
||||
return principalFrom(user), nil
|
||||
principal := principalFrom(user)
|
||||
for code, allowed := range p.Registry.rolePermissions(user.Role.Code) {
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
if principal.PermissionGrants == nil {
|
||||
principal.PermissionGrants = map[string]bool{}
|
||||
}
|
||||
principal.PermissionGrants[code] = true
|
||||
}
|
||||
return principal, nil
|
||||
}
|
||||
|
||||
func principalFrom(user BackendUser) *bouncer.Principal {
|
||||
principal := &bouncer.Principal{
|
||||
ID: user.ID,
|
||||
Backend: true,
|
||||
IsSuperuser: user.IsSuperuser,
|
||||
PermissionGrants: parseGrants(user.Role.Permissions),
|
||||
}
|
||||
|
||||
@@ -205,6 +205,13 @@ func TestAdminAuthLogging(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
user := insertAdmin(t, gdb, "logged", "logged@example.test", adminTestPassword, true, false)
|
||||
var publisherID uint
|
||||
if err := gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'publisher'`).Scan(&publisherID).Error; err != nil || publisherID == 0 {
|
||||
t.Fatalf("publisher role: id=%d err=%v", publisherID, err)
|
||||
}
|
||||
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("role_id", publisherID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
|
||||
@@ -436,6 +443,9 @@ func (p demoPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (p demoPlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{demoController{}}
|
||||
}
|
||||
func (p demoPlugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{{Code: "acme.demo.access", Roles: []string{"developer"}}}
|
||||
}
|
||||
func (p demoPlugin) AdminFS() fs.FS { return p.fsys }
|
||||
|
||||
type demoController struct{}
|
||||
|
||||
@@ -75,7 +75,11 @@ type CompiledController struct {
|
||||
|
||||
// Registry is the immutable controller map keyed by controller ID.
|
||||
type Registry struct {
|
||||
byID map[string]*CompiledController
|
||||
byID map[string]*CompiledController
|
||||
permissions map[string]pact.Permission
|
||||
roleGrants map[string]map[string]bool
|
||||
navigation []pact.NavigationItem
|
||||
settings map[string]*CompiledSetting
|
||||
}
|
||||
|
||||
// Get returns the compiled controller for a D-09 id (vendor.plugin.controller).
|
||||
@@ -87,6 +91,15 @@ func (r *Registry) Get(id string) (*CompiledController, bool) {
|
||||
return cc, ok && cc != nil
|
||||
}
|
||||
|
||||
// Setting returns one compiled singleton setting by stable code.
|
||||
func (r *Registry) Setting(code string) (*CompiledSetting, bool) {
|
||||
if r == nil {
|
||||
return nil, false
|
||||
}
|
||||
setting, ok := r.settings[code]
|
||||
return setting, ok && setting != 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.
|
||||
|
||||
@@ -394,13 +394,14 @@ func hookFixture(t *testing.T) (CRUDService, *service, *CompiledController, *gor
|
||||
func principalCtx(hooks *hookController, principal *bouncer.Principal) context.Context {
|
||||
ctx := context.WithValue(context.Background(), hookSinkKey{}, hooks.log)
|
||||
if principal != nil {
|
||||
principal.Backend = true
|
||||
ctx = bouncer.WithUser(ctx, principal)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func superUser() *bouncer.Principal {
|
||||
return &bouncer.Principal{ID: 1, IsSuperuser: true}
|
||||
return &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
|
||||
}
|
||||
|
||||
func crudCall(svc *service, method, id string, body []byte, ctx context.Context) *httptest.ResponseRecorder {
|
||||
|
||||
108
cabana/http.go
108
cabana/http.go
@@ -58,6 +58,9 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := compileContributions(reg, plugins); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if app == nil {
|
||||
return nil, errors.New("cabana: app is nil")
|
||||
}
|
||||
@@ -69,7 +72,7 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
}
|
||||
}
|
||||
bl := adminBlacklist(app)
|
||||
guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app}, bl, writeUnauthenticated)
|
||||
guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app, reg: reg}, bl, writeUnauthenticated)
|
||||
if _, err := guards.Middleware("backend"); err != nil {
|
||||
if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
|
||||
return nil, err
|
||||
@@ -102,6 +105,7 @@ func writeUnauthenticated(w http.ResponseWriter, _ error) {
|
||||
|
||||
type lazyBackendUsers struct {
|
||||
app *backpack.App
|
||||
reg *Registry
|
||||
}
|
||||
|
||||
func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
|
||||
@@ -112,7 +116,7 @@ func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Princ
|
||||
if !ok || db == nil {
|
||||
return nil, errors.New("cabana: database is not configured")
|
||||
}
|
||||
return (BackendUsers{DB: db}).FindByID(ctx, id)
|
||||
return (BackendUsers{DB: db, Registry: p.reg}).FindByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *service) mount(r pact.Router) {
|
||||
@@ -124,6 +128,14 @@ func (s *service) mount(r pact.Router) {
|
||||
r.GroupRaw("/_admin/api/v1", []string{"backend"}, func(g pact.Router) {
|
||||
g.Post("/auth/logout", s.logout)
|
||||
g.Get("/auth/me", s.me)
|
||||
g.Get("/navigation", s.navigation)
|
||||
g.Get("/settings", s.settingsList)
|
||||
g.Get("/settings/{code}/schema", s.settingsSchema)
|
||||
constrainSetting(g)
|
||||
g.Get("/settings/{code}", s.settingsGet)
|
||||
constrainSetting(g)
|
||||
g.Put("/settings/{code}", s.settingsPut)
|
||||
constrainSetting(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema)
|
||||
constrainController(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}/schema/form", s.formSchema)
|
||||
@@ -164,6 +176,96 @@ func constrainRelation(g pact.Router) {
|
||||
g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
|
||||
}
|
||||
|
||||
func constrainSetting(g pact.Router) {
|
||||
g.Where("code", "[A-Za-z_][A-Za-z0-9_-]*")
|
||||
}
|
||||
|
||||
func (s *service) navigation(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := bouncer.User(r.Context())
|
||||
if !ok || principal == nil || !principal.Backend {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
return
|
||||
}
|
||||
navigation, _ := s.reg.Metadata(r.Context(), principal, s.translator())
|
||||
WriteData(w, http.StatusOK, navigation, map[string]any{"locale": schemaLocale(r.Context(), s.translator())})
|
||||
}
|
||||
|
||||
func (s *service) settingsList(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := bouncer.User(r.Context())
|
||||
if !ok || principal == nil || !principal.Backend {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
return
|
||||
}
|
||||
_, settings := s.reg.Metadata(r.Context(), principal, s.translator())
|
||||
WriteData(w, http.StatusOK, settings, map[string]any{"locale": schemaLocale(r.Context(), s.translator())})
|
||||
}
|
||||
|
||||
func (s *service) settingsSchema(w http.ResponseWriter, r *http.Request) {
|
||||
s.protectSetting(w, r, func(setting *CompiledSetting) {
|
||||
view, err := setting.Form.Localize(r.Context(), s.translator(), nil)
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, view, map[string]any{"locale": view.Meta.Locale})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) settingsGet(w http.ResponseWriter, r *http.Request) {
|
||||
s.protectSetting(w, r, func(setting *CompiledSetting) {
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
result, err := (SettingsService{DB: db}).Get(r.Context(), setting)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, result, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) settingsPut(w http.ResponseWriter, r *http.Request) {
|
||||
s.protectSetting(w, r, func(setting *CompiledSetting) {
|
||||
body, err := decodeObject(r)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
result, err := (SettingsService{DB: db}).Put(r.Context(), setting, body)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, result, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) protectSetting(w http.ResponseWriter, r *http.Request, fn func(*CompiledSetting)) {
|
||||
principal, ok := bouncer.User(r.Context())
|
||||
if !ok || principal == nil || !principal.Backend {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
return
|
||||
}
|
||||
setting, exists := s.reg.Setting(r.PathValue("code"))
|
||||
if !exists {
|
||||
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
||||
return
|
||||
}
|
||||
if !Allows(principal, setting.Item.Permissions) {
|
||||
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
||||
return
|
||||
}
|
||||
fn(setting)
|
||||
}
|
||||
|
||||
func (s *service) relationSchema(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
cr, err := relationOf(cc, r.PathValue("name"))
|
||||
@@ -495,7 +597,7 @@ func (s *service) protect(w http.ResponseWriter, r *http.Request, fn func(*Compi
|
||||
return
|
||||
}
|
||||
principal, _ := bouncer.User(r.Context())
|
||||
if principal == nil {
|
||||
if principal == nil || !principal.Backend {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
return
|
||||
}
|
||||
|
||||
213
cabana/metadata_settings_test.go
Normal file
213
cabana/metadata_settings_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"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"
|
||||
)
|
||||
|
||||
type contributionPlugin struct {
|
||||
id string
|
||||
permissions []pact.Permission
|
||||
navigation []pact.NavigationItem
|
||||
settings []pact.SettingsItem
|
||||
fsys fs.FS
|
||||
}
|
||||
|
||||
func (p contributionPlugin) ID() string { return p.id }
|
||||
func (p contributionPlugin) Requires() []string { return nil }
|
||||
func (p contributionPlugin) Register(*backpack.App) error { return nil }
|
||||
func (p contributionPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (p contributionPlugin) Permissions() []pact.Permission { return p.permissions }
|
||||
func (p contributionPlugin) Navigation() []pact.NavigationItem { return p.navigation }
|
||||
func (p contributionPlugin) Settings() []pact.SettingsItem { return p.settings }
|
||||
func (p contributionPlugin) AdminFS() fs.FS { return p.fsys }
|
||||
|
||||
type metadataController struct{ id string }
|
||||
|
||||
func (c metadataController) ID() string { return c.id }
|
||||
func (metadataController) ModelName() string { return "Metadata" }
|
||||
func (metadataController) ConfigDir() string { return "controllers/metadata" }
|
||||
func (metadataController) RequiredPermissions() []string { return []string{"acme.demo.access"} }
|
||||
|
||||
type singletonSetting struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Enabled bool `gorm:"column:enabled"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (singletonSetting) TableName() string { return "cabana_singleton_settings" }
|
||||
func (singletonSetting) Fillable() []string { return []string{"enabled"} }
|
||||
func (singletonSetting) Rules() map[string]string { return map[string]string{"enabled": "boolean"} }
|
||||
|
||||
func TestMetadataPermissionRegistry(t *testing.T) {
|
||||
plugin := metadataPlugin()
|
||||
reg := metadataRegistry()
|
||||
if err := compileContributions(reg, []party.Plugin{plugin}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grants := reg.rolePermissions("developer")
|
||||
if !grants["acme.demo.access"] || !grants["acme.demo.manage_settings"] {
|
||||
t.Fatalf("developer grants = %#v", grants)
|
||||
}
|
||||
if grants := reg.rolePermissions("publisher"); len(grants) != 0 {
|
||||
t.Fatalf("publisher grants = %#v", grants)
|
||||
}
|
||||
|
||||
bad := metadataRegistry()
|
||||
bad.byID["acme.demo.widgets"].Controller = metadataController{id: "acme.demo.widgets"}
|
||||
bad.byID["acme.demo.widgets"].Controller = unknownPermissionController{}
|
||||
if err := compileContributions(bad, []party.Plugin{plugin}); err == nil || !strings.Contains(err.Error(), "unknown permission") {
|
||||
t.Fatalf("unknown permission error = %v", err)
|
||||
}
|
||||
|
||||
duplicate := contributionPlugin{id: "other.demo", permissions: plugin.permissions}
|
||||
if err := compileContributions(metadataRegistry(), []party.Plugin{plugin, duplicate}); err == nil || !strings.Contains(err.Error(), "duplicate permission") {
|
||||
t.Fatalf("duplicate permission error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataFiltering(t *testing.T) {
|
||||
plugin := metadataPlugin()
|
||||
reg := metadataRegistry()
|
||||
if err := compileContributions(reg, []party.Plugin{plugin}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
developer := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: reg.rolePermissions("developer")}
|
||||
navigation, settings := reg.Metadata(context.Background(), developer, nil)
|
||||
if len(navigation) != 1 || len(navigation[0].SideMenu) != 1 || navigation[0].Code != "demo" || navigation[0].SideMenu[0].Code != "widgets" {
|
||||
t.Fatalf("developer navigation = %#v", navigation)
|
||||
}
|
||||
if len(settings) != 1 || settings[0].Code != "demo" || !reflect.DeepEqual(settings[0].Keywords, []string{"demo", "settings"}) {
|
||||
t.Fatalf("developer settings = %#v", settings)
|
||||
}
|
||||
|
||||
denied := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{}}
|
||||
navigation, settings = reg.Metadata(context.Background(), denied, nil)
|
||||
if navigation == nil || settings == nil || len(navigation) != 0 || len(settings) != 0 {
|
||||
t.Fatalf("denied metadata = %#v %#v", navigation, settings)
|
||||
}
|
||||
frontend := &bouncer.Principal{ID: 1, PermissionGrants: reg.rolePermissions("developer")}
|
||||
navigation, settings = reg.Metadata(context.Background(), frontend, nil)
|
||||
if len(navigation) != 0 || len(settings) != 0 {
|
||||
t.Fatalf("frontend principal leaked metadata = %#v %#v", navigation, settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsSingletonLifecycle(t *testing.T) {
|
||||
_, db := newListService(t)
|
||||
if err := db.Migrator().DropTable(&singletonSetting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&singletonSetting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setting, err := compileSetting("acme.demo", metadataPlugin().settings[0], metadataFS())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := SettingsService{DB: db}
|
||||
missing, err := svc.Get(context.Background(), setting)
|
||||
if err != nil || missing.Exists || missing.Data["enabled"] != false {
|
||||
t.Fatalf("missing = %#v err=%v", missing, err)
|
||||
}
|
||||
assertSettingCount(t, db, 0)
|
||||
if _, err := svc.Put(context.Background(), setting, map[string]any{}); err == nil {
|
||||
t.Fatal("missing required setting succeeded")
|
||||
}
|
||||
created, err := svc.Put(context.Background(), setting, map[string]any{"enabled": true, "id": 99})
|
||||
if err != nil || !created.Exists || created.Data["enabled"] != true {
|
||||
t.Fatalf("created = %#v err=%v", created, err)
|
||||
}
|
||||
assertSettingCount(t, db, 1)
|
||||
var before singletonSetting
|
||||
if err := db.First(&before, 1).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replayed, err := svc.Put(context.Background(), setting, map[string]any{"enabled": true})
|
||||
if err != nil || replayed.Data["enabled"] != true {
|
||||
t.Fatalf("replay = %#v err=%v", replayed, err)
|
||||
}
|
||||
var after singletonSetting
|
||||
if err := db.First(&after, 1).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !after.UpdatedAt.Equal(before.UpdatedAt) || after.ID != 1 {
|
||||
t.Fatalf("replay mutated singleton before=%+v after=%+v", before, after)
|
||||
}
|
||||
if _, err := svc.Put(context.Background(), setting, map[string]any{"enabled": "not-bool"}); err == nil {
|
||||
t.Fatal("invalid boolean succeeded")
|
||||
} else {
|
||||
var invalid *ValidationError
|
||||
if !errors.As(err, &invalid) {
|
||||
t.Fatalf("invalid boolean error = %T %v", err, err)
|
||||
}
|
||||
}
|
||||
var preserved singletonSetting
|
||||
if err := db.First(&preserved, 1).Error; err != nil || !preserved.Enabled {
|
||||
t.Fatalf("failed update was not rolled back: %+v err=%v", preserved, err)
|
||||
}
|
||||
}
|
||||
|
||||
type unknownPermissionController struct{}
|
||||
|
||||
func (unknownPermissionController) ID() string { return "acme.demo.widgets" }
|
||||
func (unknownPermissionController) ModelName() string { return "Metadata" }
|
||||
func (unknownPermissionController) ConfigDir() string { return "controllers/metadata" }
|
||||
func (unknownPermissionController) RequiredPermissions() []string {
|
||||
return []string{"acme.demo.unknown"}
|
||||
}
|
||||
|
||||
func metadataRegistry() *Registry {
|
||||
return &Registry{byID: map[string]*CompiledController{
|
||||
"acme.demo.widgets": {Controller: metadataController{id: "acme.demo.widgets"}, Relations: map[string]*CompiledRelation{}},
|
||||
}}
|
||||
}
|
||||
|
||||
func metadataPlugin() contributionPlugin {
|
||||
return contributionPlugin{
|
||||
id: "acme.demo",
|
||||
permissions: []pact.Permission{
|
||||
{Code: "acme.demo.access", Roles: []string{"developer"}},
|
||||
{Code: "acme.demo.manage_settings", Roles: []string{"developer"}},
|
||||
},
|
||||
navigation: []pact.NavigationItem{{
|
||||
Code: "demo", Label: "Demo", Icon: "icon", Order: 20, Controller: "acme.demo.widgets",
|
||||
Permissions: []string{"acme.demo.*"},
|
||||
SideMenu: []pact.NavigationItem{{Code: "widgets", Label: "Widgets", Controller: "acme.demo.widgets", Permissions: []string{"acme.demo.access"}}},
|
||||
}},
|
||||
settings: []pact.SettingsItem{{
|
||||
Code: "demo", Label: "Settings", Description: "Description", Category: "Demo", Icon: "icon-cog",
|
||||
Model: "Settings", Order: 20, Keywords: []string{"demo", "settings"}, Permissions: []string{"acme.demo.manage_settings"},
|
||||
Form: "models/settings/fields.yaml", NewModel: func() any { return &singletonSetting{} },
|
||||
}},
|
||||
fsys: metadataFS(),
|
||||
}
|
||||
}
|
||||
|
||||
func metadataFS() fs.FS {
|
||||
return fstest.MapFS{"models/settings/fields.yaml": {Data: []byte("fields:\n enabled:\n type: switch\n default: 0\n required: true\n")}}
|
||||
}
|
||||
|
||||
func assertSettingCount(t *testing.T, db *gorm.DB, want int64) {
|
||||
t.Helper()
|
||||
var got int64
|
||||
if err := db.Model(&singletonSetting{}).Count(&got).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("settings count=%d want=%d", got, want)
|
||||
}
|
||||
}
|
||||
94
cabana/navigation.go
Normal file
94
cabana/navigation.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"git.golem15.com/golem15/summercms/bouncer"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/phrasebook"
|
||||
)
|
||||
|
||||
// NavigationEntry is one permission-filtered backend navigation item.
|
||||
type NavigationEntry struct {
|
||||
Code string `json:"code"`
|
||||
Label string `json:"label"`
|
||||
Icon string `json:"icon"`
|
||||
Order int `json:"order"`
|
||||
Controller string `json:"controller"`
|
||||
SideMenu []NavigationEntry `json:"sideMenu"`
|
||||
}
|
||||
|
||||
// SettingsEntry is one permission-filtered settings-list item.
|
||||
type SettingsEntry struct {
|
||||
Code string `json:"code"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Icon string `json:"icon"`
|
||||
Order int `json:"order"`
|
||||
Keywords []string `json:"keywords"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// Metadata returns only entries the backend principal may open. Denied
|
||||
// entries are removed before any response value is constructed.
|
||||
func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, tr *phrasebook.Translator) ([]NavigationEntry, []SettingsEntry) {
|
||||
navigation := make([]NavigationEntry, 0)
|
||||
settings := make([]SettingsEntry, 0)
|
||||
if r == nil || principal == nil || !principal.Backend {
|
||||
return navigation, settings
|
||||
}
|
||||
for _, item := range r.navigation {
|
||||
children := make([]NavigationEntry, 0)
|
||||
for _, child := range item.SideMenu {
|
||||
if !Allows(principal, child.Permissions) {
|
||||
continue
|
||||
}
|
||||
children = append(children, navigationView(ctx, tr, child, nil))
|
||||
}
|
||||
if !Allows(principal, item.Permissions) && len(children) == 0 {
|
||||
continue
|
||||
}
|
||||
navigation = append(navigation, navigationView(ctx, tr, item, children))
|
||||
}
|
||||
sort.SliceStable(navigation, func(i, j int) bool {
|
||||
if navigation[i].Order == navigation[j].Order {
|
||||
return navigation[i].Code < navigation[j].Code
|
||||
}
|
||||
return navigation[i].Order < navigation[j].Order
|
||||
})
|
||||
for _, compiled := range r.settings {
|
||||
item := compiled.Item
|
||||
if !Allows(principal, item.Permissions) {
|
||||
continue
|
||||
}
|
||||
keywords := append([]string(nil), item.Keywords...)
|
||||
if keywords == nil {
|
||||
keywords = []string{}
|
||||
}
|
||||
settings = append(settings, SettingsEntry{
|
||||
Code: item.Code, Label: translateKey(ctx, tr, item.Label),
|
||||
Description: translateKey(ctx, tr, item.Description),
|
||||
Category: translateKey(ctx, tr, item.Category), Icon: item.Icon,
|
||||
Order: item.Order, Keywords: keywords, Model: item.Model,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(settings, func(i, j int) bool {
|
||||
if settings[i].Order == settings[j].Order {
|
||||
return settings[i].Code < settings[j].Code
|
||||
}
|
||||
return settings[i].Order < settings[j].Order
|
||||
})
|
||||
return navigation, settings
|
||||
}
|
||||
|
||||
func navigationView(ctx context.Context, tr *phrasebook.Translator, item pact.NavigationItem, children []NavigationEntry) NavigationEntry {
|
||||
if children == nil {
|
||||
children = []NavigationEntry{}
|
||||
}
|
||||
return NavigationEntry{
|
||||
Code: item.Code, Label: translateKey(ctx, tr, item.Label), Icon: item.Icon,
|
||||
Order: item.Order, Controller: item.Controller, SideMenu: children,
|
||||
}
|
||||
}
|
||||
@@ -71,3 +71,185 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
|
||||
}
|
||||
return &Registry{byID: byID}, nil
|
||||
}
|
||||
|
||||
func compileContributions(reg *Registry, plugins []party.Plugin) error {
|
||||
if reg == nil {
|
||||
return fmt.Errorf("cabana: registry is nil")
|
||||
}
|
||||
reg.permissions = map[string]pact.Permission{}
|
||||
reg.roleGrants = map[string]map[string]bool{}
|
||||
reg.settings = map[string]*CompiledSetting{}
|
||||
seenNavigation := map[string]struct{}{}
|
||||
|
||||
for _, plugin := range plugins {
|
||||
if plugin == nil {
|
||||
continue
|
||||
}
|
||||
if src, ok := plugin.(pact.HasPermissions); ok && src != nil {
|
||||
for _, permission := range src.Permissions() {
|
||||
if !permissionCode(permission.Code, false) {
|
||||
return fmt.Errorf("cabana: plugin %s registered invalid permission %q", plugin.ID(), permission.Code)
|
||||
}
|
||||
if _, exists := reg.permissions[permission.Code]; exists {
|
||||
return fmt.Errorf("cabana: duplicate permission %s", permission.Code)
|
||||
}
|
||||
reg.permissions[permission.Code] = permission
|
||||
for _, role := range permission.Roles {
|
||||
role = strings.TrimSpace(role)
|
||||
if role == "" {
|
||||
return fmt.Errorf("cabana: permission %s has an empty role", permission.Code)
|
||||
}
|
||||
if reg.roleGrants[role] == nil {
|
||||
reg.roleGrants[role] = map[string]bool{}
|
||||
}
|
||||
reg.roleGrants[role][permission.Code] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if src, ok := plugin.(pact.HasSettings); ok && src != nil {
|
||||
assets, hasAssets := plugin.(pact.AdminAssets)
|
||||
for _, item := range src.Settings() {
|
||||
if !identifier(item.Code) {
|
||||
return fmt.Errorf("cabana: plugin %s registered invalid setting %q", plugin.ID(), item.Code)
|
||||
}
|
||||
if _, exists := reg.settings[item.Code]; exists {
|
||||
return fmt.Errorf("cabana: duplicate setting %s", item.Code)
|
||||
}
|
||||
if !hasAssets || assets == nil || assets.AdminFS() == nil {
|
||||
return fmt.Errorf("cabana: plugin %s has settings but no AdminFS", plugin.ID())
|
||||
}
|
||||
compiled, err := compileSetting(plugin.ID(), item, assets.AdminFS())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reg.settings[item.Code] = compiled
|
||||
}
|
||||
}
|
||||
if src, ok := plugin.(pact.HasNavigation); ok && src != nil {
|
||||
for _, item := range src.Navigation() {
|
||||
if err := validateNavigationShape(plugin.ID(), item, seenNavigation); err != nil {
|
||||
return err
|
||||
}
|
||||
reg.navigation = append(reg.navigation, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for id, controller := range reg.byID {
|
||||
if err := reg.validatePermissions("controller "+id, requiredOf(controller.Controller)); err != nil {
|
||||
return err
|
||||
}
|
||||
for name, relation := range controller.Relations {
|
||||
if err := reg.validatePermissions("relation "+id+"."+name, relation.RequiredPermissions); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, item := range reg.navigation {
|
||||
if err := reg.validateNavigation(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for code, setting := range reg.settings {
|
||||
if err := reg.validatePermissions("setting "+code, setting.Item.Permissions); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func permissionCode(code string, wildcard bool) bool {
|
||||
parts := strings.Split(code, ".")
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
for i, part := range parts {
|
||||
if wildcard && i == len(parts)-1 && part == "*" {
|
||||
return true
|
||||
}
|
||||
if !identifier(part) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Registry) validatePermissions(owner string, permissions []string) error {
|
||||
for _, code := range permissions {
|
||||
if !permissionCode(code, true) || !r.permissionExists(code) {
|
||||
return fmt.Errorf("cabana: %s references unknown permission %s", owner, code)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) permissionExists(code string) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
if _, ok := r.permissions[code]; ok {
|
||||
return true
|
||||
}
|
||||
if !strings.HasSuffix(code, ".*") {
|
||||
return false
|
||||
}
|
||||
prefix := strings.TrimSuffix(code, "*")
|
||||
for candidate := range r.permissions {
|
||||
if strings.HasPrefix(candidate, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateNavigationShape(pluginID string, item pact.NavigationItem, seen map[string]struct{}) error {
|
||||
if !identifier(item.Code) {
|
||||
return fmt.Errorf("cabana: plugin %s registered invalid navigation code %q", pluginID, item.Code)
|
||||
}
|
||||
if _, exists := seen[item.Code]; exists {
|
||||
return fmt.Errorf("cabana: duplicate navigation code %s", item.Code)
|
||||
}
|
||||
seen[item.Code] = struct{}{}
|
||||
childSeen := map[string]struct{}{}
|
||||
for _, child := range item.SideMenu {
|
||||
if !identifier(child.Code) {
|
||||
return fmt.Errorf("cabana: navigation %s has invalid child code %q", item.Code, child.Code)
|
||||
}
|
||||
if _, exists := childSeen[child.Code]; exists {
|
||||
return fmt.Errorf("cabana: navigation %s has duplicate child %s", item.Code, child.Code)
|
||||
}
|
||||
childSeen[child.Code] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) validateNavigation(item pact.NavigationItem) error {
|
||||
if _, ok := r.byID[item.Controller]; !ok {
|
||||
return fmt.Errorf("cabana: navigation %s references unknown controller %s", item.Code, item.Controller)
|
||||
}
|
||||
if err := r.validatePermissions("navigation "+item.Code, item.Permissions); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, child := range item.SideMenu {
|
||||
if _, ok := r.byID[child.Controller]; !ok {
|
||||
return fmt.Errorf("cabana: navigation %s.%s references unknown controller %s", item.Code, child.Code, child.Controller)
|
||||
}
|
||||
if err := r.validatePermissions("navigation "+item.Code+"."+child.Code, child.Permissions); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) rolePermissions(role string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if r == nil {
|
||||
return out
|
||||
}
|
||||
for code, allowed := range r.roleGrants[role] {
|
||||
if allowed {
|
||||
out[code] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ func controllerRequest(principal *bouncer.Principal) *http.Request {
|
||||
req.SetPathValue("plugin", "demo")
|
||||
req.SetPathValue("controller", "widgets")
|
||||
if principal != nil {
|
||||
principal.Backend = true
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
|
||||
}
|
||||
return req
|
||||
|
||||
308
cabana/settings.go
Normal file
308
cabana/settings.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/lagoon"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// CompiledSetting is a singleton settings registration after strict schema
|
||||
// compilation and writable-field binding.
|
||||
type CompiledSetting struct {
|
||||
PluginID string
|
||||
Item pact.SettingsItem
|
||||
Form *FormSchema
|
||||
Writable []WritableField
|
||||
}
|
||||
|
||||
// SettingsResult is the explicit missing/existing singleton response.
|
||||
type SettingsResult struct {
|
||||
Exists bool `json:"exists"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
// SettingsService reads and transactionally updates compiled singleton rows.
|
||||
type SettingsService struct{ DB *gorm.DB }
|
||||
|
||||
func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*CompiledSetting, error) {
|
||||
if item.NewModel == nil {
|
||||
return nil, fmt.Errorf("cabana: setting %s has no model factory", item.Code)
|
||||
}
|
||||
model := item.NewModel()
|
||||
rv := reflect.ValueOf(model)
|
||||
if model == nil || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
|
||||
return nil, fmt.Errorf("cabana: setting %s model must be a non-nil struct pointer", item.Code)
|
||||
}
|
||||
if _, ok := model.(lagoon.HasFillable); !ok {
|
||||
return nil, fmt.Errorf("cabana: setting %s model has no Fillable", item.Code)
|
||||
}
|
||||
if _, ok := model.(hasRules); !ok {
|
||||
return nil, fmt.Errorf("cabana: setting %s model has no Rules", item.Code)
|
||||
}
|
||||
formPath := strings.Trim(path.Clean(item.Form), "/")
|
||||
if formPath == "." || strings.HasPrefix(formPath, "..") {
|
||||
return nil, fmt.Errorf("cabana: setting %s form escapes the plugin", item.Code)
|
||||
}
|
||||
raw, err := readAsset(fsys, formPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cabana: plugin %s setting %s schema %s: %w", pluginID, item.Code, formPath, err)
|
||||
}
|
||||
fields, err := decodeFields(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cabana: plugin %s setting %s schema %s: %w", pluginID, item.Code, formPath, err)
|
||||
}
|
||||
form := &FormSchema{Name: item.Label, ModelClass: item.Model, Fields: fields}
|
||||
columns := modelColumns(model)
|
||||
fillable := map[string]struct{}{}
|
||||
for _, key := range model.(lagoon.HasFillable).Fillable() {
|
||||
fillable[key] = struct{}{}
|
||||
}
|
||||
writable := make([]WritableField, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
if !scalarFormField(field.Type) || protectedFillKey(field.Name) {
|
||||
continue
|
||||
}
|
||||
if _, ok := columns[field.Name]; !ok {
|
||||
return nil, fmt.Errorf("cabana: setting %s field %s is not a model column", item.Code, field.Name)
|
||||
}
|
||||
if _, ok := fillable[field.Name]; !ok {
|
||||
return nil, fmt.Errorf("cabana: setting %s field %s is not fillable", item.Code, field.Name)
|
||||
}
|
||||
writable = append(writable, WritableField{Name: field.Name, FillKey: field.Name})
|
||||
}
|
||||
return &CompiledSetting{PluginID: pluginID, Item: item, Form: form, Writable: writable}, nil
|
||||
}
|
||||
|
||||
// Get returns default-valued data without creating a missing singleton.
|
||||
func (s SettingsService) Get(ctx context.Context, setting *CompiledSetting) (SettingsResult, error) {
|
||||
if s.DB == nil {
|
||||
return SettingsResult{}, errors.New("cabana: database is not configured")
|
||||
}
|
||||
model, err := newSettingModel(setting)
|
||||
if err != nil {
|
||||
return SettingsResult{}, err
|
||||
}
|
||||
if err := applySettingDefaults(setting, model); err != nil {
|
||||
return SettingsResult{}, err
|
||||
}
|
||||
err = s.DB.WithContext(ctx).Where(clause.Eq{Column: clause.Column{Name: primaryColumn(model)}, Value: 1}).Take(model).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return SettingsResult{Exists: false, Data: projectSetting(setting, model)}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return SettingsResult{}, err
|
||||
}
|
||||
return SettingsResult{Exists: true, Data: projectSetting(setting, model)}, nil
|
||||
}
|
||||
|
||||
// Put validates and persists exactly one settings row. Identical replay is a
|
||||
// successful no-op, including no updated_at churn.
|
||||
func (s SettingsService) Put(ctx context.Context, setting *CompiledSetting, body map[string]any) (SettingsResult, error) {
|
||||
if s.DB == nil {
|
||||
return SettingsResult{}, errors.New("cabana: database is not configured")
|
||||
}
|
||||
if err := requireSettingInputs(setting, body); err != nil {
|
||||
return SettingsResult{}, err
|
||||
}
|
||||
var result SettingsResult
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
model, err := newSettingModel(setting)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pk := primaryColumn(model)
|
||||
err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where(clause.Eq{Column: clause.Column{Name: pk}, Value: 1}).Take(model).Error
|
||||
exists := err == nil
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
if err := applySettingDefaults(setting, model); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := setColumn(model, pk, uint(1)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
before := projectSetting(setting, model)
|
||||
projected := projectSettingBody(setting, body)
|
||||
allowed := make([]string, 0, len(setting.Writable))
|
||||
for _, field := range setting.Writable {
|
||||
allowed = append(allowed, field.FillKey)
|
||||
}
|
||||
if err := lagoon.Fill(model, allowed, projected, false); err != nil {
|
||||
return &ValidationError{Details: map[string]any{"body": []string{"The settings payload is invalid."}}}
|
||||
}
|
||||
if hook, ok := model.(lagoon.HasBeforeValidate); ok && hook != nil {
|
||||
if err := hook.BeforeValidate(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
rules := settingRules(setting, model)
|
||||
messages, err := lagoon.Validate(ctx, tx, model, rules, valuesForRules(model, rules), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(messages) > 0 {
|
||||
return &ValidationError{Details: validationDetails(messages)}
|
||||
}
|
||||
after := projectSetting(setting, model)
|
||||
if !exists {
|
||||
if err := tx.Create(model).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !reflect.DeepEqual(before, after) {
|
||||
if err := tx.Save(model).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result = SettingsResult{Exists: true, Data: after}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func newSettingModel(setting *CompiledSetting) (any, error) {
|
||||
if setting == nil || setting.Item.NewModel == nil {
|
||||
return nil, errors.New("cabana: setting model is not configured")
|
||||
}
|
||||
model := setting.Item.NewModel()
|
||||
rv := reflect.ValueOf(model)
|
||||
if model == nil || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
|
||||
return nil, errors.New("cabana: setting model is invalid")
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func applySettingDefaults(setting *CompiledSetting, model any) error {
|
||||
defaults := map[string]any{}
|
||||
for _, field := range setting.Form.Fields {
|
||||
if field.Default == nil {
|
||||
continue
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(field.Default.raw, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
if field.Type == "switch" || field.Type == "checkbox" {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
value = typed != 0
|
||||
case json.Number:
|
||||
value = typed.String() != "0"
|
||||
}
|
||||
}
|
||||
defaults[field.Name] = value
|
||||
}
|
||||
return lagoon.Fill(model, settingWritable(setting), defaults, true)
|
||||
}
|
||||
|
||||
func settingWritable(setting *CompiledSetting) []string {
|
||||
out := make([]string, 0, len(setting.Writable))
|
||||
for _, field := range setting.Writable {
|
||||
out = append(out, field.FillKey)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func projectSettingBody(setting *CompiledSetting, body map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
if setting == nil {
|
||||
return out
|
||||
}
|
||||
for _, field := range setting.Writable {
|
||||
value, ok := body[field.Name]
|
||||
if ok && !nestedValue(value) {
|
||||
out[field.FillKey] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func projectSetting(setting *CompiledSetting, model any) map[string]any {
|
||||
out := map[string]any{}
|
||||
value := reflect.ValueOf(model)
|
||||
for value.IsValid() && value.Kind() == reflect.Pointer {
|
||||
if value.IsNil() {
|
||||
return out
|
||||
}
|
||||
value = value.Elem()
|
||||
}
|
||||
if !value.IsValid() || value.Kind() != reflect.Struct || setting == nil {
|
||||
return out
|
||||
}
|
||||
for _, field := range setting.Writable {
|
||||
v := fieldByColumn(value, field.FillKey)
|
||||
if v.IsValid() && v.CanInterface() {
|
||||
out[field.Name] = v.Interface()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func requireSettingInputs(setting *CompiledSetting, body map[string]any) error {
|
||||
details := map[string]any{}
|
||||
for _, field := range setting.Form.Fields {
|
||||
value, present := body[field.Name]
|
||||
if present && nestedValue(value) {
|
||||
details[field.Name] = []string{"The " + field.Name + " field is invalid."}
|
||||
continue
|
||||
}
|
||||
if field.Required && !present {
|
||||
details[field.Name] = []string{"The " + field.Name + " field is required."}
|
||||
}
|
||||
}
|
||||
if len(details) > 0 {
|
||||
return &ValidationError{Details: details}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingRules(setting *CompiledSetting, model any) map[string]string {
|
||||
rules := map[string]string{}
|
||||
if provider, ok := model.(hasRules); ok && provider != nil {
|
||||
for key, rule := range provider.Rules() {
|
||||
rules[key] = rule
|
||||
}
|
||||
}
|
||||
for _, field := range setting.Form.Fields {
|
||||
if field.Required {
|
||||
rules[field.Name] = mergeRequired(rules[field.Name])
|
||||
}
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func setColumn(model any, column string, value any) error {
|
||||
rv := reflect.ValueOf(model)
|
||||
for rv.Kind() == reflect.Pointer {
|
||||
if rv.IsNil() {
|
||||
return errors.New("cabana: nil model")
|
||||
}
|
||||
rv = rv.Elem()
|
||||
}
|
||||
field := fieldByColumn(rv, column)
|
||||
if !field.IsValid() || !field.CanSet() {
|
||||
return fmt.Errorf("cabana: model has no writable %s column", column)
|
||||
}
|
||||
src := reflect.ValueOf(value)
|
||||
if src.Type().AssignableTo(field.Type()) {
|
||||
field.Set(src)
|
||||
return nil
|
||||
}
|
||||
if src.Type().ConvertibleTo(field.Type()) {
|
||||
field.Set(src.Convert(field.Type()))
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("cabana: cannot set %s", column)
|
||||
}
|
||||
@@ -145,6 +145,7 @@ type HasPermissions interface {
|
||||
// NavigationItem is one registerNavigation() entry. Controller is the admin
|
||||
// controller ID; the SPA derives its route from that ID.
|
||||
type NavigationItem struct {
|
||||
Code string
|
||||
Label string
|
||||
Icon string
|
||||
Permissions []string
|
||||
@@ -169,6 +170,8 @@ type SettingsItem struct {
|
||||
Order int
|
||||
Keywords []string
|
||||
Permissions []string
|
||||
Form string `json:"-"`
|
||||
NewModel func() any `json:"-"`
|
||||
}
|
||||
|
||||
// HasSettings is implemented by plugins that declare settings screens.
|
||||
|
||||
Reference in New Issue
Block a user