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"` } 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 } 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 } // 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/); 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 } // 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()) }