- the settings schema passes the settings model as the DropdownOptionsProvider - boot refuses a dropdown method on a settings model without DropdownOptions - cabana README and docs/backend/settings.md describe it
417 lines
14 KiB
Go
417 lines
14 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"path"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
"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
|
|
// Secrets are the names of the form's write-only password fields, in
|
|
// form order (Phase 18 D-13): lagoon.Encrypted columns whose value never
|
|
// leaves the server.
|
|
Secrets []string
|
|
}
|
|
|
|
// SettingsResult is the explicit missing/existing singleton response. Data
|
|
// never holds a password field; Meta reports each one's state and the
|
|
// richeditor save notices, and is omitted when there is nothing to report.
|
|
type SettingsResult struct {
|
|
Exists bool `json:"exists"`
|
|
Data map[string]any `json:"data"`
|
|
Meta *SettingsMeta `json:"meta,omitempty"`
|
|
}
|
|
|
|
// SettingsService reads and transactionally updates compiled singleton rows.
|
|
// Tr localizes the 422 messages and notices of the settings Put; nil keeps
|
|
// the phrase keys. Config resolves the SecretFallbacks keys of a settings
|
|
// model for the env state of its secrets; nil reports no env state.
|
|
type SettingsService struct {
|
|
DB *gorm.DB
|
|
Tr *phrasebook.Translator
|
|
Config *compass.Config
|
|
}
|
|
|
|
// 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) {
|
|
return compileSettingWithTypes(pluginID, item, fsys, nil)
|
|
}
|
|
|
|
// compileSettingWithTypes is compileSetting with the plugin field types of the
|
|
// boot: a settings form refuses them (supported in controller forms only).
|
|
func compileSettingWithTypes(pluginID string, item pact.SettingsItem, fsys fs.FS, types *fieldTypeRegistry) (*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, types.refusing())
|
|
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 == 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)
|
|
}
|
|
if err := settingsFormOnly(field); err != nil {
|
|
return nil, fmt.Errorf("cabana: setting %s field %s: %w", item.Code, field.Name, err)
|
|
}
|
|
// A method-backed dropdown takes its options from the settings model,
|
|
// as a relation form takes them from its related model.
|
|
if field.optionsMethod != "" && modelDropdownProvider(model) == nil {
|
|
return nil, fmt.Errorf("cabana: setting %s field %s: dropdown method %s requires DropdownOptions on the settings model", item.Code, field.Name, field.optionsMethod)
|
|
}
|
|
}
|
|
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))
|
|
var secrets []string
|
|
for _, field := range fields {
|
|
// A password field is a write-only secret on a settings form (D-13):
|
|
// bound like a scalar, checked against lagoon.Encrypted, never served.
|
|
secret := field.Type == passwordFieldType
|
|
if (!scalarFormField(field.Type) && !secret) || 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)
|
|
}
|
|
}
|
|
if field.Type == moneyType {
|
|
if err := checkMoneyType(model, field); err != nil {
|
|
return nil, fmt.Errorf("cabana: setting %s: %w", item.Code, err)
|
|
}
|
|
}
|
|
if secret {
|
|
if err := checkSecretType(model, field); err != nil {
|
|
return nil, fmt.Errorf("cabana: setting %s: %w", item.Code, err)
|
|
}
|
|
secrets = append(secrets, field.Name)
|
|
}
|
|
writable = append(writable, WritableField{Name: field.Name, FillKey: field.Name})
|
|
}
|
|
return &CompiledSetting{PluginID: pluginID, Item: item, Form: form, Writable: writable, Secrets: secrets}, nil
|
|
}
|
|
|
|
// settingDropdownProvider is the settings model of setting as the provider
|
|
// of its method-backed dropdown options, or nil.
|
|
func settingDropdownProvider(setting *CompiledSetting) pact.DropdownOptionsProvider {
|
|
model, err := newSettingModel(setting)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return modelDropdownProvider(model)
|
|
}
|
|
|
|
// 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
|
|
exists := err == nil
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return SettingsResult{}, err
|
|
}
|
|
return SettingsResult{
|
|
Exists: exists,
|
|
Data: withoutSecrets(setting, projectSetting(setting, model)),
|
|
Meta: settingsMeta(ctx, s.Tr, secretStates(setting, model, s.Config), nil),
|
|
}, 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
|
|
}
|
|
if details := moneyDetails(ctx, s.Tr, setting.Form.Fields, body, nil); len(details) > 0 {
|
|
return SettingsResult{}, &ValidationError{Details: details}
|
|
}
|
|
// D-13: an absent or empty secret keeps the stored value, null clears it.
|
|
if err := liftSecretValues(setting, body); err != nil {
|
|
return SettingsResult{}, err
|
|
}
|
|
// A richeditor value is a string or null; anything else is refused on
|
|
// the field instead of being dropped by the projection.
|
|
if err := checkSettingRichEditors(setting, body); err != nil {
|
|
return SettingsResult{}, err
|
|
}
|
|
var (
|
|
result SettingsResult
|
|
notices []pluginNotice
|
|
)
|
|
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)
|
|
// Every richeditor value is sanitized before Fill sees it, as on a
|
|
// controller save.
|
|
notices = sanitizeSettingRichEditors(setting, projected)
|
|
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: withoutSecrets(setting, after),
|
|
Meta: settingsMeta(ctx, s.Tr, secretStates(setting, model, s.Config), notices),
|
|
}
|
|
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
|
|
}
|
|
// An absent secret keeps its stored value, so required never applies.
|
|
if field.Required && !present && field.Type != passwordFieldType {
|
|
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)
|
|
}
|