- placement: tabs before/after tab anchors, anchor cursor for plugin order, own-field anchors sorted with cycle detection, Requires() rule, anchor tab inheritance, Hide with reference checks, zone refusal (D-101) - boot errors name plugin, controller and offending name; fileupload is refused (D-100); localized tab label collisions checked in Activate - extension widgets dispatch to AdminFormExtension.Actions, partials render from the contributor's Dir through PartialData, plugin field types decode for Save and load after Load, translatable values reach Save as locale maps and never the owner's translations - Permissions and Visible apply to schema, show, save, widgets and partials; AdminFormErrors answer 422, Save notices join meta.notices
432 lines
16 KiB
Go
432 lines
16 KiB
Go
package cabana
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
"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"`
|
|
|
|
// priorPermissions is the stored grants the Roles screen saw before the
|
|
// permission editor wrote the submitted ones (priorLoaded set), so the
|
|
// save knows whether the role's holders changed what they may do
|
|
// (pact.MCPSubjectChanged). Unexported: GORM skips it.
|
|
priorPermissions string
|
|
priorLoaded bool
|
|
}
|
|
|
|
func (BackendUserRole) TableName() string { return "backend_user_roles" }
|
|
|
|
// Fillable lists the role columns the admin's Roles screen writes from a
|
|
// form: name, code and description. permissions and is_system are never
|
|
// filled (the permission editor stores the grants, and is_system only comes
|
|
// from migrations).
|
|
func (BackendUserRole) Fillable() []string { return []string{"name", "code", "description"} }
|
|
|
|
// Rules are the role's admin save rules: a name unique among roles and a
|
|
// code. The code's uniqueness is checked by the Roles screen itself.
|
|
func (BackendUserRole) Rules() map[string]string {
|
|
return map[string]string{
|
|
"name": "required|max:255|unique:backend_user_roles",
|
|
"code": "required|max:255",
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
// Metadata is Winter's jsonable metadata column kept as raw text, so a copied
|
|
// value that is not a JSON object (NULL, blank or []) still loads; read it
|
|
// through Subtitle.
|
|
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"`
|
|
Metadata *string `gorm:"column:metadata"`
|
|
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
|
|
|
|
// priorRoleID is the stored role_id the Administrators screen saw
|
|
// before a save assigned the submitted role (priorLoaded set), so the
|
|
// save can refuse a change of the editor's own role after the
|
|
// assignment. Unexported: GORM and the admin's field binding skip it.
|
|
priorRoleID *uint
|
|
priorLoaded bool
|
|
// priorPermissions and priorSuperuser are the stored overrides and flag
|
|
// at the same moment, so the save can tell whether what the
|
|
// administrator may do changed (pact.MCPSubjectChanged).
|
|
priorPermissions string
|
|
priorSuperuser bool
|
|
}
|
|
|
|
func (BackendUser) TableName() string { return "backend_users" }
|
|
|
|
// Fillable lists the columns the admin's Administrators screen writes from a
|
|
// form: login, email, first and last name. Role, permissions, the superuser
|
|
// flag, activation and the password are protected and written only by the
|
|
// screen's own checks.
|
|
func (BackendUser) Fillable() []string {
|
|
return []string{"login", "email", "first_name", "last_name"}
|
|
}
|
|
|
|
// The Administrators list filter scopes: filterByRole keeps the holders of
|
|
// one role, filterTrashed shows or hides deleted administrators.
|
|
const (
|
|
filterByRole = "filterByRole"
|
|
filterTrashed = "filterTrashed"
|
|
)
|
|
|
|
// The filterTrashed values.
|
|
const (
|
|
trashedHide = "hide"
|
|
trashedOnly = "only"
|
|
)
|
|
|
|
// FilterScopes lists the scope names the Administrators list filters call.
|
|
func (BackendUser) FilterScopes() []string { return []string{filterByRole, filterTrashed} }
|
|
|
|
// FilterScope narrows db for the scope name. filterByRole keeps the holders
|
|
// of the role whose id is value; a value that is not a role id matches
|
|
// nothing. filterTrashed with "hide" leaves deleted administrators out and
|
|
// with "only" keeps only them; any other value does not narrow. Any other
|
|
// name matches nothing; a nil db is returned as it is.
|
|
func (BackendUser) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
|
|
if db == nil {
|
|
return db
|
|
}
|
|
if name == filterTrashed {
|
|
switch strings.TrimSpace(fmt.Sprint(value)) {
|
|
case trashedHide:
|
|
return db.Where("backend_users.deleted_at IS NULL")
|
|
case trashedOnly:
|
|
return db.Where("backend_users.deleted_at IS NOT NULL")
|
|
}
|
|
return db
|
|
}
|
|
if name != filterByRole {
|
|
return db.Where("1 = 0")
|
|
}
|
|
id, ok := filterID(value)
|
|
if !ok {
|
|
return db.Where("1 = 0")
|
|
}
|
|
return db.Where("backend_users.role_id = ?", id)
|
|
}
|
|
|
|
// filterID reads a filter value as a positive whole number, given as a
|
|
// number or as its decimal text.
|
|
func filterID(value any) (uint64, bool) {
|
|
if value == nil {
|
|
return 0, false
|
|
}
|
|
text := strings.TrimSpace(fmt.Sprint(value))
|
|
id, err := strconv.ParseUint(text, 10, 32)
|
|
if err != nil || id == 0 {
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// Rules are the administrator's admin save rules. The uniqueness of login
|
|
// and email, deleted administrators included and ignoring case, is checked
|
|
// by the Administrators screen itself.
|
|
func (BackendUser) Rules() map[string]string {
|
|
return map[string]string{
|
|
"login": "required|max:255",
|
|
"email": "required|email|max:255",
|
|
}
|
|
}
|
|
|
|
const (
|
|
// BackendUserMorphType is the system_files attachment_type of a backend
|
|
// user's files: WinterCMS's class string, so avatar rows copied from
|
|
// WinterCMS match as they are.
|
|
BackendUserMorphType = "Backend\\Models\\User"
|
|
// BackendUserAvatarField is the attachOne relation (system_files.field)
|
|
// of a backend user's avatar.
|
|
BackendUserAvatarField = "avatar"
|
|
)
|
|
|
|
// MorphName implements attach.Owner with WinterCMS's class string.
|
|
func (BackendUser) MorphName() string { return BackendUserMorphType }
|
|
|
|
// AttachRelations implements attach.HasRelations: one public attachOne
|
|
// avatar, as WinterCMS's Backend\Models\User declares it.
|
|
func (BackendUser) AttachRelations() []attach.Relation {
|
|
return []attach.Relation{{Name: BackendUserAvatarField, Public: true}}
|
|
}
|
|
|
|
// Subtitle returns metadata.subtitle, the key WinterCMS's Journal author
|
|
// component reads. NULL, blank, null, [], invalid JSON and a non-string value
|
|
// all read as an empty subtitle.
|
|
func (u BackendUser) Subtitle() string { return metadataString(u.Metadata, metadataSubtitle) }
|
|
|
|
// 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
|
|
// contributed maps the name of each record action another plugin added
|
|
// through pact.HasAdminActionExtensions to that plugin's ID.
|
|
contributed map[string]string
|
|
|
|
// 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
|
|
// reorder is the compiled config_reorder.yaml; nil without one (D-09).
|
|
reorder *compiledReorder
|
|
// singleton is true for a controller implementing pact.AdminSingleton
|
|
// (D-11): one record, opened directly; no list, create, bulk or delete.
|
|
singleton bool
|
|
// formExtensions are the form contributions other plugins (or the owner)
|
|
// add through pact.AdminActionExtension.Form (D-91), in plugin activation
|
|
// order; extensionFields maps the name of every field they add to its
|
|
// extension.
|
|
formExtensions []*compiledFormExtension
|
|
extensionFields map[string]*compiledFormExtension
|
|
// extensionPartials maps the name of every partial a form extension's
|
|
// partial field renders to its extension.
|
|
extensionPartials map[string]*compiledFormExtension
|
|
}
|
|
|
|
// Registry is the immutable controller map keyed by controller ID.
|
|
type Registry struct {
|
|
byID map[string]*CompiledController
|
|
permissions map[string]pact.Permission
|
|
// permissionOrder holds the permission codes in registration order
|
|
// (plugin activation order, then each plugin's declared order).
|
|
permissionOrder []string
|
|
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
|
|
// fieldTypes are the plugin field types of this boot
|
|
// (pact.HasFormFieldTypes); nil when no plugin registers one.
|
|
fieldTypes *fieldTypeRegistry
|
|
// tokenScopes is the compiled personal API token catalog (* , admin:read,
|
|
// then plugin scopes in activation order).
|
|
tokenScopes *tokenScopeCatalog
|
|
// accountContributions are the My account sections plugins contribute
|
|
// (pact.HasAccountContributions), in activation order; nil before
|
|
// compileAccountContributions.
|
|
accountContributions *accountContributionCatalog
|
|
}
|
|
|
|
// 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. When the
|
|
// principal carries a non-full personal API token scope limit, the scope is
|
|
// evaluated first (owner ∩ scope); a superuser with a scoped token is still
|
|
// limited by the scope, and an empty requirement list is refused. Otherwise
|
|
// superusers pass and 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.Scope != nil && !principal.Scope.Full {
|
|
return allowsUnderScope(principal, required)
|
|
}
|
|
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 {
|
|
moduleLog.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())
|
|
}
|