pact.SettingsItem gains an additive Controller field, the equivalent of a WinterCMS registerSettings 'url' => Backend::url(...) entry. A link entry declares no Model, Form or NewModel and needs no AdminFS; start-up fails when it combines Controller with a singleton form or a model, or names an unregistered controller. Settings codes stay one namespace. GET /settings lists a link entry with its controller only when the principal passes the item's permissions and may open the controller. Registry.Setting never returns a link entry, so the singleton settings endpoints answer 404 for its code. SettingsEntry carries controller, empty for singletons; the OpenAPI document, generated SPA types and settings fixture follow, and the pact and cabana READMEs and the settings docs describe the link.
337 lines
11 KiB
Go
337 lines
11 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"path"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// CompiledSetting is a singleton settings registration after strict schema
|
|
// compilation and writable-field binding. For a link entry (Item.Controller
|
|
// set) Form and Writable are nil.
|
|
type CompiledSetting struct {
|
|
PluginID string
|
|
Item pact.SettingsItem
|
|
Form *FormSchema
|
|
Writable []WritableField
|
|
}
|
|
|
|
// SettingsResult is the explicit missing/existing singleton response.
|
|
type SettingsResult struct {
|
|
Exists bool `json:"exists"`
|
|
Data map[string]any `json:"data"`
|
|
}
|
|
|
|
// SettingsService reads and transactionally updates compiled singleton rows.
|
|
type SettingsService struct{ DB *gorm.DB }
|
|
|
|
// compileSettingLink registers a settings entry that opens an admin
|
|
// controller. It declares no singleton form and no model; the controller ID
|
|
// itself is checked against the registry once every plugin is collected.
|
|
func compileSettingLink(pluginID string, item pact.SettingsItem) (*CompiledSetting, error) {
|
|
if item.Form != "" || item.NewModel != nil {
|
|
return nil, fmt.Errorf("cabana: setting %s links controller %s and declares a singleton form; declare one", item.Code, item.Controller)
|
|
}
|
|
if item.Model != "" {
|
|
return nil, fmt.Errorf("cabana: setting %s links controller %s and names a model", item.Code, item.Controller)
|
|
}
|
|
return &CompiledSetting{PluginID: pluginID, Item: item}, nil
|
|
}
|
|
|
|
func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*CompiledSetting, error) {
|
|
if item.NewModel == nil {
|
|
return nil, fmt.Errorf("cabana: setting %s has no model factory", item.Code)
|
|
}
|
|
model := item.NewModel()
|
|
rv := reflect.ValueOf(model)
|
|
if model == nil || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
|
|
return nil, fmt.Errorf("cabana: setting %s model must be a non-nil struct pointer", item.Code)
|
|
}
|
|
if _, ok := model.(lagoon.HasFillable); !ok {
|
|
return nil, fmt.Errorf("cabana: setting %s model has no Fillable", item.Code)
|
|
}
|
|
if _, ok := model.(hasRules); !ok {
|
|
return nil, fmt.Errorf("cabana: setting %s model has no Rules", item.Code)
|
|
}
|
|
formPath := strings.Trim(path.Clean(item.Form), "/")
|
|
if formPath == "." || strings.HasPrefix(formPath, "..") {
|
|
return nil, fmt.Errorf("cabana: setting %s form escapes the plugin", item.Code)
|
|
}
|
|
raw, err := readAsset(fsys, formPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cabana: plugin %s setting %s schema %s: %w", pluginID, item.Code, formPath, err)
|
|
}
|
|
fields, err := decodeFields(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cabana: plugin %s setting %s schema %s: %w", pluginID, item.Code, formPath, err)
|
|
}
|
|
for _, field := range fields {
|
|
// A settings screen has no admin controller to own actions or view models.
|
|
if field.Type == "widget" || field.Type == "partial" || field.Type == "fileupload" || field.Type == "password" || field.Type == permissionFieldType {
|
|
return nil, fmt.Errorf("cabana: setting %s field %s: type %s is not supported on a settings form", item.Code, field.Name, field.Type)
|
|
}
|
|
if field.Preset != nil {
|
|
return nil, fmt.Errorf("cabana: setting %s field %s: preset is not supported on a settings form", item.Code, field.Name)
|
|
}
|
|
}
|
|
form := &FormSchema{Name: item.Label, ModelClass: item.Model, Fields: fields}
|
|
columns := modelColumns(model)
|
|
fillable := map[string]struct{}{}
|
|
for _, key := range model.(lagoon.HasFillable).Fillable() {
|
|
fillable[key] = struct{}{}
|
|
}
|
|
writable := make([]WritableField, 0, len(fields))
|
|
for _, field := range fields {
|
|
if !scalarFormField(field.Type) || protectedFillKey(field.Name) {
|
|
continue
|
|
}
|
|
if _, ok := columns[field.Name]; !ok {
|
|
return nil, fmt.Errorf("cabana: setting %s field %s is not a model column", item.Code, field.Name)
|
|
}
|
|
if _, ok := fillable[field.Name]; !ok {
|
|
return nil, fmt.Errorf("cabana: setting %s field %s is not fillable", item.Code, field.Name)
|
|
}
|
|
if field.Type == "datepicker" {
|
|
if err := checkDateType(model, field); err != nil {
|
|
return nil, fmt.Errorf("cabana: setting %s: %w", item.Code, err)
|
|
}
|
|
}
|
|
writable = append(writable, WritableField{Name: field.Name, FillKey: field.Name})
|
|
}
|
|
return &CompiledSetting{PluginID: pluginID, Item: item, Form: form, Writable: writable}, nil
|
|
}
|
|
|
|
// Get returns default-valued data without creating a missing singleton.
|
|
func (s SettingsService) Get(ctx context.Context, setting *CompiledSetting) (SettingsResult, error) {
|
|
if s.DB == nil {
|
|
return SettingsResult{}, errors.New("cabana: database is not configured")
|
|
}
|
|
model, err := newSettingModel(setting)
|
|
if err != nil {
|
|
return SettingsResult{}, err
|
|
}
|
|
if err := applySettingDefaults(setting, model); err != nil {
|
|
return SettingsResult{}, err
|
|
}
|
|
err = s.DB.WithContext(ctx).Where(clause.Eq{Column: clause.Column{Name: primaryColumn(model)}, Value: 1}).Take(model).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return SettingsResult{Exists: false, Data: projectSetting(setting, model)}, nil
|
|
}
|
|
if err != nil {
|
|
return SettingsResult{}, err
|
|
}
|
|
return SettingsResult{Exists: true, Data: projectSetting(setting, model)}, nil
|
|
}
|
|
|
|
// Put validates and persists exactly one settings row. Identical replay is a
|
|
// successful no-op, including no updated_at churn.
|
|
func (s SettingsService) Put(ctx context.Context, setting *CompiledSetting, body map[string]any) (SettingsResult, error) {
|
|
if s.DB == nil {
|
|
return SettingsResult{}, errors.New("cabana: database is not configured")
|
|
}
|
|
if err := requireSettingInputs(setting, body); err != nil {
|
|
return SettingsResult{}, err
|
|
}
|
|
var result SettingsResult
|
|
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
model, err := newSettingModel(setting)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pk := primaryColumn(model)
|
|
err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where(clause.Eq{Column: clause.Column{Name: pk}, Value: 1}).Take(model).Error
|
|
exists := err == nil
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
if !exists {
|
|
if err := applySettingDefaults(setting, model); err != nil {
|
|
return err
|
|
}
|
|
if err := setColumn(model, pk, uint(1)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
before := projectSetting(setting, model)
|
|
projected := projectSettingBody(setting, body)
|
|
allowed := make([]string, 0, len(setting.Writable))
|
|
for _, field := range setting.Writable {
|
|
allowed = append(allowed, field.FillKey)
|
|
}
|
|
if err := lagoon.Fill(model, allowed, projected, false); err != nil {
|
|
return &ValidationError{Details: map[string]any{"body": []string{"The settings payload is invalid."}}}
|
|
}
|
|
if hook, ok := model.(lagoon.HasBeforeValidate); ok && hook != nil {
|
|
if err := hook.BeforeValidate(tx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
rules := settingRules(setting, model)
|
|
messages, err := lagoon.Validate(ctx, tx, model, rules, valuesForRules(model, rules), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(messages) > 0 {
|
|
return &ValidationError{Details: validationDetails(messages)}
|
|
}
|
|
after := projectSetting(setting, model)
|
|
if !exists {
|
|
if err := tx.Create(model).Error; err != nil {
|
|
return err
|
|
}
|
|
} else if !reflect.DeepEqual(before, after) {
|
|
if err := tx.Save(model).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
result = SettingsResult{Exists: true, Data: after}
|
|
return nil
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
func newSettingModel(setting *CompiledSetting) (any, error) {
|
|
if setting == nil || setting.Item.NewModel == nil {
|
|
return nil, errors.New("cabana: setting model is not configured")
|
|
}
|
|
model := setting.Item.NewModel()
|
|
rv := reflect.ValueOf(model)
|
|
if model == nil || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
|
|
return nil, errors.New("cabana: setting model is invalid")
|
|
}
|
|
return model, nil
|
|
}
|
|
|
|
func applySettingDefaults(setting *CompiledSetting, model any) error {
|
|
defaults := map[string]any{}
|
|
for _, field := range setting.Form.Fields {
|
|
if field.Default == nil {
|
|
continue
|
|
}
|
|
var value any
|
|
if err := json.Unmarshal(field.Default.raw, &value); err != nil {
|
|
return err
|
|
}
|
|
if field.Type == "switch" || field.Type == "checkbox" {
|
|
switch typed := value.(type) {
|
|
case float64:
|
|
value = typed != 0
|
|
case json.Number:
|
|
value = typed.String() != "0"
|
|
}
|
|
}
|
|
defaults[field.Name] = value
|
|
}
|
|
return lagoon.Fill(model, settingWritable(setting), defaults, true)
|
|
}
|
|
|
|
func settingWritable(setting *CompiledSetting) []string {
|
|
out := make([]string, 0, len(setting.Writable))
|
|
for _, field := range setting.Writable {
|
|
out = append(out, field.FillKey)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func projectSettingBody(setting *CompiledSetting, body map[string]any) map[string]any {
|
|
out := map[string]any{}
|
|
if setting == nil {
|
|
return out
|
|
}
|
|
for _, field := range setting.Writable {
|
|
value, ok := body[field.Name]
|
|
if ok && !nestedValue(value) {
|
|
out[field.FillKey] = value
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func projectSetting(setting *CompiledSetting, model any) map[string]any {
|
|
out := map[string]any{}
|
|
value := reflect.ValueOf(model)
|
|
for value.IsValid() && value.Kind() == reflect.Pointer {
|
|
if value.IsNil() {
|
|
return out
|
|
}
|
|
value = value.Elem()
|
|
}
|
|
if !value.IsValid() || value.Kind() != reflect.Struct || setting == nil {
|
|
return out
|
|
}
|
|
for _, field := range setting.Writable {
|
|
v := fieldByColumn(value, field.FillKey)
|
|
if v.IsValid() && v.CanInterface() {
|
|
out[field.Name] = v.Interface()
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func requireSettingInputs(setting *CompiledSetting, body map[string]any) error {
|
|
details := map[string]any{}
|
|
for _, field := range setting.Form.Fields {
|
|
value, present := body[field.Name]
|
|
if present && nestedValue(value) {
|
|
details[field.Name] = []string{"The " + field.Name + " field is invalid."}
|
|
continue
|
|
}
|
|
if field.Required && !present {
|
|
details[field.Name] = []string{"The " + field.Name + " field is required."}
|
|
}
|
|
}
|
|
if len(details) > 0 {
|
|
return &ValidationError{Details: details}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func settingRules(setting *CompiledSetting, model any) map[string]string {
|
|
rules := map[string]string{}
|
|
if provider, ok := model.(hasRules); ok && provider != nil {
|
|
for key, rule := range provider.Rules() {
|
|
rules[key] = rule
|
|
}
|
|
}
|
|
for _, field := range setting.Form.Fields {
|
|
if field.Required {
|
|
rules[field.Name] = mergeRequired(rules[field.Name])
|
|
}
|
|
}
|
|
return rules
|
|
}
|
|
|
|
func setColumn(model any, column string, value any) error {
|
|
rv := reflect.ValueOf(model)
|
|
for rv.Kind() == reflect.Pointer {
|
|
if rv.IsNil() {
|
|
return errors.New("cabana: nil model")
|
|
}
|
|
rv = rv.Elem()
|
|
}
|
|
field := fieldByColumn(rv, column)
|
|
if !field.IsValid() || !field.CanSet() {
|
|
return fmt.Errorf("cabana: model has no writable %s column", column)
|
|
}
|
|
src := reflect.ValueOf(value)
|
|
if src.Type().AssignableTo(field.Type()) {
|
|
field.Set(src)
|
|
return nil
|
|
}
|
|
if src.Type().ConvertibleTo(field.Type()) {
|
|
field.Set(src.Convert(field.Type()))
|
|
return nil
|
|
}
|
|
return fmt.Errorf("cabana: cannot set %s", column)
|
|
}
|