329 lines
9.7 KiB
Go
329 lines
9.7 KiB
Go
package cabana
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/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
|
|
}
|
|
|
|
// 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 := 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 !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 _, 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
|
|
}
|