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) }