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.
256 lines
9.1 KiB
Go
256 lines
9.1 KiB
Go
package cabana
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// BackendUserRole is the Winter backend_user_roles row.
|
|
type BackendUserRole struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Name string `gorm:"column:name"`
|
|
Code string `gorm:"column:code"`
|
|
Description string `gorm:"column:description"`
|
|
Permissions string `gorm:"column:permissions"`
|
|
IsSystem bool `gorm:"column:is_system"`
|
|
CreatedAt time.Time `gorm:"column:created_at"`
|
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
|
}
|
|
|
|
func (BackendUserRole) TableName() string { return "backend_user_roles" }
|
|
|
|
// BackendUser is the Winter backend_users row. It is not a frontend user.
|
|
// Nullable Winter columns are mapped so a copied row round-trips without a
|
|
// schema transform. TokensValidAfter is the admin reset cutoff, not a Winter column.
|
|
type BackendUser struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
FirstName string `gorm:"column:first_name"`
|
|
LastName string `gorm:"column:last_name"`
|
|
Login string `gorm:"column:login"`
|
|
Email string `gorm:"column:email"`
|
|
Password string `gorm:"column:password"`
|
|
ActivationCode string `gorm:"column:activation_code"`
|
|
PersistCode string `gorm:"column:persist_code"`
|
|
ResetPasswordCode string `gorm:"column:reset_password_code"`
|
|
Permissions string `gorm:"column:permissions"`
|
|
IsActivated bool `gorm:"column:is_activated"`
|
|
IsSuperuser bool `gorm:"column:is_superuser"`
|
|
RoleID *uint `gorm:"column:role_id"`
|
|
ActivatedAt *time.Time `gorm:"column:activated_at"`
|
|
LastLogin *time.Time `gorm:"column:last_login"`
|
|
CreatedAt time.Time `gorm:"column:created_at"`
|
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
|
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
|
TokensValidAfter *time.Time `gorm:"column:tokens_valid_after"`
|
|
Role BackendUserRole
|
|
}
|
|
|
|
func (BackendUser) TableName() string { return "backend_users" }
|
|
|
|
// Option is a dropdown choice shared with later schema plans.
|
|
type Option = pact.Option
|
|
|
|
// WritableField is one schema field bound to a model fill key at activation.
|
|
// FillKey comes from the model column, not from request JSON.
|
|
type WritableField struct {
|
|
Name string
|
|
FillKey string
|
|
}
|
|
|
|
// CompiledController is one admin controller after YAML compilation.
|
|
type CompiledController struct {
|
|
PluginID string
|
|
Controller pact.AdminController
|
|
List *ListSchema
|
|
Form *FormSchema
|
|
Relations map[string]*CompiledRelation
|
|
Writable []WritableField
|
|
// FieldRelations are the form's `type: relation` fields keyed by field name.
|
|
FieldRelations map[string]*CompiledFieldRelation
|
|
// Actions are the controller's pact.HasAdminActions entries keyed by name:
|
|
// the single namespace that toolbar.buttons names and widget action: keys
|
|
// resolve through. create and delete are reserved built-in names.
|
|
Actions map[string]pact.AdminAction
|
|
// BulkActions are the controller's pact.HasAdminBulkActions entries keyed
|
|
// by name. They have their own namespace next to Actions; create and
|
|
// delete are reserved there too.
|
|
BulkActions map[string]pact.AdminBulkAction
|
|
// RecordActions are the controller's pact.HasAdminRecordActions entries
|
|
// keyed by name, a third namespace with the same reserved names.
|
|
RecordActions map[string]pact.AdminRecordAction
|
|
|
|
// scripts and styles are the controller's declared plugin JS and CSS,
|
|
// in declared order.
|
|
scripts []*pluginAsset
|
|
styles []*pluginAsset
|
|
// partials are the parsed controller partials (headerPartial and every
|
|
// `type: partial` path) keyed by name; formPartials are the names form
|
|
// fields declare, the only ones a request may render with a record id.
|
|
partials map[string]*compiledPartial
|
|
formPartials map[string]bool
|
|
// files are the form's `type: fileupload` fields bound to the model's
|
|
// attach.Relation, keyed by field name.
|
|
files map[string]*compiledFile
|
|
// dates are the form's `type: datepicker` fields after the Go type
|
|
// check, keyed by field name.
|
|
dates map[string]*compiledDate
|
|
// virtual is the set of form field names the controller lists through
|
|
// pact.FormVirtualFields: never bound, filled or projected.
|
|
virtual map[string]bool
|
|
// permissions are the form's `type: permissioneditor` fields: field name
|
|
// to mode (radio or checkbox).
|
|
permissions map[string]string
|
|
}
|
|
|
|
// Registry is the immutable controller map keyed by controller ID.
|
|
type Registry struct {
|
|
byID map[string]*CompiledController
|
|
permissions map[string]pact.Permission
|
|
roleGrants map[string]map[string]bool
|
|
navigation []pact.NavigationItem
|
|
settings map[string]*CompiledSetting
|
|
// assets are every controller's declared plugin files keyed by URL tail
|
|
// (vendor/plugin/<path after assets/>); the asset route serves only these.
|
|
assets map[string]*pluginAsset
|
|
}
|
|
|
|
// Get returns the compiled controller for a D-09 id (vendor.plugin.controller).
|
|
func (r *Registry) Get(id string) (*CompiledController, bool) {
|
|
if r == nil {
|
|
return nil, false
|
|
}
|
|
cc, ok := r.byID[id]
|
|
return cc, ok && cc != nil
|
|
}
|
|
|
|
// Setting returns one compiled singleton setting by stable code. Link entries
|
|
// (pact.SettingsItem with Controller set) are not singletons and are not
|
|
// returned, so the singleton settings endpoints answer 404 for their codes.
|
|
func (r *Registry) Setting(code string) (*CompiledSetting, bool) {
|
|
if r == nil {
|
|
return nil, false
|
|
}
|
|
setting, ok := r.settings[code]
|
|
if !ok || setting == nil || setting.Item.Controller != "" {
|
|
return nil, false
|
|
}
|
|
return setting, true
|
|
}
|
|
|
|
// Allows reports whether principal satisfies any of the required permission
|
|
// codes, the way Winter's hasAnyAccess does. A nil principal fails. Superusers
|
|
// pass. An empty requirement list allows any authenticated principal. Both
|
|
// sides may use a wildcard: a grant ending in ".*" covers every code with that
|
|
// prefix, and a required code ending in ".*" (or starting with "*") is met by
|
|
// any granted code that matches it.
|
|
func Allows(principal *bouncer.Principal, required []string) bool {
|
|
if principal == nil {
|
|
return false
|
|
}
|
|
if principal.IsSuperuser || len(required) == 0 {
|
|
return true
|
|
}
|
|
for _, code := range required {
|
|
if granted(principal.PermissionGrants, code) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// granted ports Winter's User::hasPermission for one code. Only enabled
|
|
// grants are in the map, so the "(int) $value === 1" test is already applied.
|
|
func granted(grants map[string]bool, code string) bool {
|
|
switch {
|
|
case len(code) > 1 && strings.HasSuffix(code, "*"):
|
|
prefix := strings.TrimSuffix(code, "*")
|
|
for key, on := range grants {
|
|
if on && key != prefix && strings.HasPrefix(key, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
case len(code) > 1 && strings.HasPrefix(code, "*"):
|
|
suffix := strings.TrimPrefix(code, "*")
|
|
for key, on := range grants {
|
|
if on && key != suffix && strings.HasSuffix(key, suffix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if grants[code] {
|
|
return true
|
|
}
|
|
for key, on := range grants {
|
|
if !on || len(key) < 2 || !strings.HasSuffix(key, "*") {
|
|
continue
|
|
}
|
|
prefix := strings.TrimSuffix(key, "*")
|
|
if prefix != code && strings.HasPrefix(code, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func requiredOf(ctl pact.AdminController) []string {
|
|
if p, ok := ctl.(pact.AdminPermissioned); ok && p != nil {
|
|
return p.RequiredPermissions()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WriteData writes a D-10 success envelope.
|
|
func WriteData(w http.ResponseWriter, status int, data, meta any) {
|
|
if meta == nil {
|
|
meta = map[string]any{}
|
|
}
|
|
writeJSON(w, status, map[string]any{"data": data, "meta": meta})
|
|
}
|
|
|
|
// WriteError writes a D-10 error envelope. details is always an object.
|
|
func WriteError(w http.ResponseWriter, status int, code, message string) {
|
|
WriteErrorDetails(w, status, code, message, nil)
|
|
}
|
|
|
|
// WriteErrorDetails writes a D-10 error envelope with field messages.
|
|
func WriteErrorDetails(w http.ResponseWriter, status int, code, message string, details map[string]any) {
|
|
if details == nil {
|
|
details = map[string]any{}
|
|
}
|
|
writeJSON(w, status, map[string]any{
|
|
"error": map[string]any{
|
|
"code": code,
|
|
"message": message,
|
|
"details": details,
|
|
},
|
|
})
|
|
}
|
|
|
|
// writeJSON encodes body before the status line goes out, so a value that
|
|
// cannot be encoded (NaN, an infinity, a failing MarshalJSON) is a logged 500
|
|
// with the generic body instead of a 200 with a truncated one.
|
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
|
var buf bytes.Buffer
|
|
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
|
slog.Error("cabana: response could not be encoded", "status", status, "error", err)
|
|
buf.Reset()
|
|
_ = json.NewEncoder(&buf).Encode(map[string]any{
|
|
"error": map[string]any{"code": "error", "message": msgServerError, "details": map[string]any{}},
|
|
})
|
|
status = http.StatusInternalServerError
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write(buf.Bytes())
|
|
}
|