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 // 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 } // 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/); 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. func (r *Registry) Setting(code string) (*CompiledSetting, bool) { if r == nil { return nil, false } setting, ok := r.settings[code] return setting, ok && setting != nil } // 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()) }