Files
summercms/modules/cabana/contracts.go
Jakub Zych 771d2ccce0 feat(10.1-01): render header and form partials into an allowlisted node tree
- fields.yaml type: partial with a bare path name and config_list.yaml
  headerPartial resolve to {ConfigDir}/_{name}.htm, parsed at boot; the
  controller must implement pact.AdminPartialData
- html/template render against a curated view model, then x/net/html
  ParseFragment and a tag, attribute and URL allowlist with 64 KiB, 2000-node
  and depth-32 caps; the model type and trusted template types are refused
- GET .../partials/{name} with optional ?id= loaded through the form scope
- golang.org/x/net becomes a direct requirement (D-18), no new module
2026-09-28 23:52:50 +02:00

195 lines
6.5 KiB
Go

package cabana
import (
"encoding/json"
"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/<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.
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 every required permission code.
// A nil principal fails. Superusers pass. An empty requirement list allows
// any authenticated principal. Grants ending in ".*" match by prefix.
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 false
}
}
return true
}
func granted(grants map[string]bool, code string) bool {
if grants[code] {
return true
}
for key, on := range grants {
if !on || !strings.HasSuffix(key, ".*") {
continue
}
prefix := strings.TrimSuffix(key, "*")
if 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,
},
})
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}