Files
summercms/modules/cabana/settings.go
Jakub Zych 5e50b166ef refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
2026-09-28 02:21:02 +02:00

309 lines
9.4 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.
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 }
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)
}
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)
}
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)
}