pact.SettingsItem gains an additive Controller field, the equivalent of a WinterCMS registerSettings 'url' => Backend::url(...) entry. A link entry declares no Model, Form or NewModel and needs no AdminFS; start-up fails when it combines Controller with a singleton form or a model, or names an unregistered controller. Settings codes stay one namespace. GET /settings lists a link entry with its controller only when the principal passes the item's permissions and may open the controller. Registry.Setting never returns a link entry, so the singleton settings endpoints answer 404 for its code. SettingsEntry carries controller, empty for singletons; the OpenAPI document, generated SPA types and settings fixture follow, and the pact and cabana READMEs and the settings docs describe the link.
447 lines
13 KiB
Go
447 lines
13 KiB
Go
package cabana
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
// reservedVendorSegments are the first path segments under the admin prefix
|
|
// that the SPA and API own. A controller ID maps to /{vendor}/{plugin}/..., so
|
|
// a vendor with one of these names would collide with them.
|
|
var reservedVendorSegments = map[string]bool{"api": true, "assets": true, "login": true, "settings": true}
|
|
|
|
func checkReservedSegments(items []controllerRef) error {
|
|
for _, item := range items {
|
|
id := item.ctl.ID()
|
|
vendor, _, _ := strings.Cut(id, ".")
|
|
if reservedVendorSegments[vendor] {
|
|
return fmt.Errorf("cabana: controller %s uses the reserved admin path segment %q (reserved: api, assets, login, settings)", id, vendor)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func compileRegistry(items []controllerRef) (*Registry, error) {
|
|
byID := make(map[string]*CompiledController, len(items))
|
|
assets := map[string]*pluginAsset{}
|
|
for _, item := range items {
|
|
id := item.ctl.ID()
|
|
if _, exists := byID[id]; exists {
|
|
return nil, fmt.Errorf("cabana: duplicate admin controller %s", id)
|
|
}
|
|
fsys, ok := item.plugin.(pact.AdminAssets)
|
|
if !ok || fsys == nil || fsys.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, fsys.AdminFS())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
form, err := compileFormIfPresent(item.plugin.ID(), item.ctl, fsys.AdminFS())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
relations, err := compileRelations(item.plugin.ID(), item.ctl, fsys.AdminFS(), form)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fieldRelations, err := compileFieldRelations(item.plugin.ID(), item.ctl, form)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if form == nil && list != nil {
|
|
// Without a compiled form there is nothing to create (D-14).
|
|
list.ToolbarButtons = withoutAction(list.ToolbarButtons, "create")
|
|
}
|
|
compiled := &CompiledController{
|
|
PluginID: item.plugin.ID(),
|
|
Controller: item.ctl,
|
|
List: list,
|
|
Form: form,
|
|
Relations: relations,
|
|
FieldRelations: fieldRelations,
|
|
}
|
|
if err := BindWritableFields(compiled); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := compileFileFields(item.plugin.ID(), compiled); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := compileDateFields(item.plugin.ID(), compiled); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := compilePermissionFields(item.plugin.ID(), compiled); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := compileExtension(item.plugin.ID(), compiled, fsys.AdminFS()); err != nil {
|
|
return nil, err
|
|
}
|
|
// Two controllers of one plugin declaring the same file share an entry.
|
|
for _, file := range append(append([]*pluginAsset(nil), compiled.scripts...), compiled.styles...) {
|
|
if _, exists := assets[file.key]; !exists {
|
|
assets[file.key] = file
|
|
}
|
|
}
|
|
byID[id] = compiled
|
|
}
|
|
return &Registry{byID: byID, assets: assets}, nil
|
|
}
|
|
|
|
// operationDeclared reports whether the controller's compiled list and form
|
|
// declare the write operation: create, update, delete or bulk-delete.
|
|
func (cc *CompiledController) operationDeclared(op string) bool {
|
|
switch op {
|
|
case "create":
|
|
return cc.Form != nil && (cc.List == nil || hasToolbarButton(cc.List, "create"))
|
|
case "update", "delete":
|
|
return cc.Form != nil
|
|
case "bulk-delete":
|
|
return cc.List != nil && hasToolbarButton(cc.List, "delete")
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasToolbarButton(list *ListSchema, name string) bool {
|
|
for _, button := range list.ToolbarButtons {
|
|
if button == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func withoutAction(actions []string, drop string) []string {
|
|
out := make([]string, 0, len(actions))
|
|
for _, action := range actions {
|
|
if action != drop {
|
|
out = append(out, action)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
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 item.Controller != "" {
|
|
// A link entry opens an admin controller and needs no
|
|
// singleton form, so no AdminFS either.
|
|
compiled, err := compileSettingLink(plugin.ID(), item)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
reg.settings[item.Code] = compiled
|
|
continue
|
|
}
|
|
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 name, action := range controller.Actions {
|
|
if err := reg.validatePermissions("action "+id+"."+name, action.Permissions); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for name, action := range controller.BulkActions {
|
|
if err := reg.validatePermissions("bulk action "+id+"."+name, action.Permissions); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for name, action := range controller.RecordActions {
|
|
if err := reg.validatePermissions("record action "+id+"."+name, action.Permissions); 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
|
|
}
|
|
if controller := setting.Item.Controller; controller != "" {
|
|
if _, ok := reg.byID[controller]; !ok {
|
|
return fmt.Errorf("cabana: setting %s references unknown controller %s", code, controller)
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
// checkDeferredModels stops boot when a deferrable relation that declares
|
|
// create points at a related model no activated plugin lists in
|
|
// pact.HasModels Models(): deferred:purge resolves the slave type of an
|
|
// abandoned child through those models and could not delete it (RESEARCH
|
|
// Pitfall 9). A listed model of the same Go type matches; with a database,
|
|
// a listed model of the same morph type matches too.
|
|
func checkDeferredModels(reg *Registry, plugins []party.Plugin, gdb *gorm.DB) error {
|
|
if reg == nil {
|
|
return nil
|
|
}
|
|
var models []any
|
|
for _, p := range plugins {
|
|
if hm, ok := p.(pact.HasModels); ok && hm != nil {
|
|
for _, m := range hm.Models() {
|
|
if m != nil {
|
|
models = append(models, m)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ids := make([]string, 0, len(reg.byID))
|
|
for id := range reg.byID {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Strings(ids)
|
|
for _, id := range ids {
|
|
cc := reg.byID[id]
|
|
names := make([]string, 0, len(cc.Relations))
|
|
for name := range cc.Relations {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
cr := cc.Relations[name]
|
|
if !cr.deferrable || !cr.allows("create") {
|
|
continue
|
|
}
|
|
related := cr.Contract.NewRelated()
|
|
if !modelListed(models, related, gdb) {
|
|
morph := tableName(related)
|
|
if gdb != nil {
|
|
if m, err := lagoon.MorphType(gdb, related); err == nil {
|
|
morph = m
|
|
}
|
|
} else if owner, ok := related.(interface{ MorphName() string }); ok {
|
|
morph = owner.MorphName()
|
|
}
|
|
return fmt.Errorf("cabana: admin controller %s/%s: relation %s creates %s records under deferral, but no activated plugin lists that model in Models(), so deferred:purge could not remove abandoned ones", cc.PluginID, id, name, morph)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func modelListed(models []any, related any, gdb *gorm.DB) bool {
|
|
want := reflect.TypeOf(related)
|
|
for want.Kind() == reflect.Pointer {
|
|
want = want.Elem()
|
|
}
|
|
var morph string
|
|
if gdb != nil {
|
|
morph, _ = lagoon.MorphType(gdb, related)
|
|
}
|
|
for _, m := range models {
|
|
t := reflect.TypeOf(m)
|
|
for t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
if t == want {
|
|
return true
|
|
}
|
|
if morph != "" {
|
|
if got, err := lagoon.MorphType(gdb, m); err == nil && got == morph {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|