- pact.NavigationItem gains Visible func(ctx) bool (json:"-"); nil keeps the entry always listed - cabana.Registry.Metadata runs it after the permission check, drops a main item (with its side menu) or a side item that answers false, and relinks a parent whose controller is a hidden child's to its first visible openable child - TestNavigationHidesInvisibleItemsAndRepointsTarget covers the rules - pact and cabana READMEs, users-and-permissions and admin-controllers docs describe the hook
153 lines
5.8 KiB
Go
153 lines
5.8 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/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"`
|
|
// Controller is the admin controller a link entry opens; empty for a
|
|
// singleton settings form.
|
|
Controller string `json:"controller"`
|
|
}
|
|
|
|
// Metadata returns only entries the backend principal may open. Denied
|
|
// entries are removed before any response value is constructed. A navigation
|
|
// entry with a pact.NavigationItem.Visible func is also left out when that
|
|
// func answers false for ctx (a main item with its whole side menu, or one
|
|
// side item); Visible runs only after the permission check has passed, so a
|
|
// denied principal never runs plugin code.
|
|
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 {
|
|
// Like Winter's NavigationManager, a main item the principal may not
|
|
// open is dropped whatever its children allow, so a denied parent
|
|
// never leaks its label or target controller.
|
|
if !Allows(principal, item.Permissions) {
|
|
continue
|
|
}
|
|
if item.Visible != nil && !item.Visible(ctx) {
|
|
continue
|
|
}
|
|
children := make([]NavigationEntry, 0)
|
|
// hidden collects the controllers of side items a Visible func hid
|
|
// for this request, so the parent never links to one of them.
|
|
// Permission-denied children are not recorded: the parent keeps its
|
|
// own controller whenever the principal can open it.
|
|
hidden := map[string]bool{}
|
|
for _, child := range item.SideMenu {
|
|
if !Allows(principal, child.Permissions) {
|
|
continue
|
|
}
|
|
if child.Visible != nil && !child.Visible(ctx) {
|
|
if child.Controller != "" {
|
|
hidden[child.Controller] = true
|
|
}
|
|
continue
|
|
}
|
|
children = append(children, navigationView(ctx, tr, child, nil))
|
|
}
|
|
view := navigationView(ctx, tr, item, children)
|
|
view.Controller = r.openableTarget(principal, item, children, hidden)
|
|
navigation = append(navigation, view)
|
|
}
|
|
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
|
|
// A link entry is listed only when the principal can also open its
|
|
// controller, so a denied controller ID never leaks.
|
|
if !Allows(principal, item.Permissions) || (item.Controller != "" && !r.canOpen(principal, item.Controller)) {
|
|
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,
|
|
Controller: item.Controller,
|
|
})
|
|
}
|
|
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
|
|
}
|
|
|
|
// openableTarget returns the controller a main item should link to. It is the
|
|
// item's own controller unless the principal cannot open it or it is the
|
|
// controller of a side item hidden for this request (in hidden), in which
|
|
// case it is the first of the listed children (already visible and permitted)
|
|
// the principal can open, so the menu never links to a page that answers 403
|
|
// or to a screen whose feature is off. It is empty when nothing is openable.
|
|
func (r *Registry) openableTarget(principal *bouncer.Principal, item pact.NavigationItem, children []NavigationEntry, hidden map[string]bool) string {
|
|
if item.Controller == "" || (!hidden[item.Controller] && r.canOpen(principal, item.Controller)) {
|
|
return item.Controller
|
|
}
|
|
for _, child := range children {
|
|
if child.Controller != "" && r.canOpen(principal, child.Controller) {
|
|
return child.Controller
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// canOpen reports whether principal passes a registered controller's required
|
|
// permissions. A controller the registry does not know is not blocked here:
|
|
// the guard answers for it when it is opened.
|
|
func (r *Registry) canOpen(principal *bouncer.Principal, controller string) bool {
|
|
cc, ok := r.Get(controller)
|
|
if !ok {
|
|
return true
|
|
}
|
|
return Allows(principal, requiredOf(cc.Controller))
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|