- FieldRelationContract/FieldRelationProvider bind every type: relation
field to a belongsTo foreign key or a belongsToMany pivot; activation
fails naming plugin, controller and field on a missing or broken contract
- GET /{vendor}/{plugin}/{controller}/fields/{field}/options serves
{value, label} pages scoped by pact.RelationExtendOptionsQuery, behind
the controller permission; read-only and non-relation fields are 404
- Saves apply present relation keys after the Before hook: ids are
revalidated through the same scoped query (422 and full rollback
otherwise), belongsTo sets the foreign key, belongsToMany replaces pivot
rows in submitted order with the order column set to the index
- Show, create and update return relation values in data and meta.labels
- A belongsTo on a protected fill key is read-only (D-26)
- One six-segment GET pattern dispatches relation lists and field options,
which ServeMux cannot register side by side
- Admin OpenAPI documents the options route and RecordEnvelope
277 lines
8.1 KiB
Go
277 lines
8.1 KiB
Go
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
|
|
}
|
|
|
|
// 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))
|
|
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
|
|
}
|
|
form, err := compileFormIfPresent(item.plugin.ID(), item.ctl, assets.AdminFS())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
relations, err := compileRelations(item.plugin.ID(), item.ctl, assets.AdminFS(), form)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fieldRelations, err := compileFieldRelations(item.plugin.ID(), item.ctl, form)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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
|
|
}
|
|
byID[id] = compiled
|
|
}
|
|
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
|
|
}
|