- type: fileupload compiles the D-08 keys and binds to the model's attach.Relation at boot
- X-Session-Key (cabana.SessionKeyHeader) carries the form session key; RecordInput.SessionKey
- GET and POST .../{id}/files/{field}: list with pending uploads, multipart upload into attach.Store
- the create and update save attaches the session's pending files in its transaction
- swagger2openapi folds formData parameters into a multipart requestBody
- admin OpenAPI, TS types, conformance cases, README and forms docs
235 lines
8.0 KiB
Go
235 lines
8.0 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
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 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())
|
|
}
|