package cabana import ( "context" "fmt" "reflect" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/seaglass" ) // passwordFieldType is the write-only secret type on a settings form. const passwordFieldType = "password" // SecretFallbacks is implemented by a settings model whose write-only // password fields fall back to host configuration when the column is empty // (Phase 18 D-13). The map is field name to compass config key; SUMMER_* // environment variables map onto those keys as everywhere else. A field // whose column is empty and whose key resolves to a non-empty value reports // the env state; the value itself is never read by cabana beyond that check. type SecretFallbacks interface { SecretFallbacks() map[string]string } // The states of a write-only settings secret in SettingsMeta.Secrets. const ( // SecretStored means the column holds a value. SecretStored = "stored" // SecretEnv means the column is empty and the model's SecretFallbacks // config key resolves to a non-empty value. SecretEnv = "env" // SecretEmpty means the column is empty and no fallback applies. SecretEmpty = "empty" ) // SettingsMeta is the meta of a settings result: the state of every // write-only password field (never its value) and the save notices of the // richeditor fields. The key is omitted when there is nothing to report. type SettingsMeta struct { // Secrets maps each password field to stored, env or empty. Secrets map[string]string `json:"secrets,omitempty"` // Notices are what the seaglass sanitizer removed from richeditor // values on this save. Notices []FieldNotice `json:"notices,omitempty"` } var encryptedGoType = reflect.TypeOf(lagoon.Encrypted{}) // checkSecretType refuses a password field of a settings form whose model // column is not a lagoon.Encrypted (or a pointer to one). func checkSecretType(model any, field FormField) error { sf, ok := structFieldByColumn(model, field.Name) if !ok { return fmt.Errorf("field %s is not a model column", field.Name) } t := sf.Type if t.Kind() == reflect.Pointer { t = t.Elem() } if t != encryptedGoType { return fmt.Errorf("field %s: password on a settings form needs a lagoon.Encrypted column, found %s", field.Name, sf.Type) } return nil } // secretStates reports the state of every password field of the setting on // the loaded model: stored when the column holds a value, env when it is // empty and the model's fallback key resolves in cfg, else empty. The // result is nil when the form has no password field. func secretStates(setting *CompiledSetting, model any, cfg *compass.Config) map[string]string { if setting == nil || len(setting.Secrets) == 0 { return nil } var fallbacks map[string]string if provider, ok := model.(SecretFallbacks); ok && provider != nil { fallbacks = provider.SecretFallbacks() } states := make(map[string]string, len(setting.Secrets)) for _, name := range setting.Secrets { states[name] = SecretEmpty if secretStored(model, name) { states[name] = SecretStored continue } if key := fallbacks[name]; key != "" && cfg != nil && cfg.String(key) != "" { states[name] = SecretEnv } } return states } // secretStored reports whether the model's Encrypted column holds a value. func secretStored(model any, column string) bool { v := reflect.ValueOf(model) for v.Kind() == reflect.Pointer { if v.IsNil() { return false } v = v.Elem() } f := fieldByColumn(v, column) if !f.IsValid() || !f.CanInterface() { return false } switch e := f.Interface().(type) { case lagoon.Encrypted: return e.Reveal() != "" case *lagoon.Encrypted: return e != nil && e.Reveal() != "" } return false } // liftSecretValues applies the write-only semantics to the settings body // before projection (D-13): an absent key or an empty string keeps the // stored value, so the key is dropped; JSON null clears it and stays; any // other string replaces it; a value that is neither is a 422 on the field. // The body is edited in place and the value is never copied anywhere else. func liftSecretValues(setting *CompiledSetting, body map[string]any) error { for _, name := range setting.Secrets { value, present := body[name] if !present { continue } switch v := value.(type) { case nil: case string: if v == "" { delete(body, name) } default: return &ValidationError{Details: fillTypeDetails(name)} } } return nil } // withoutSecrets is the settings data projection without the password // fields: a secret never leaves the server, in a GET or a PUT answer. func withoutSecrets(setting *CompiledSetting, data map[string]any) map[string]any { if setting == nil || len(setting.Secrets) == 0 { return data } out := make(map[string]any, len(data)) for key, value := range data { out[key] = value } for _, name := range setting.Secrets { delete(out, name) } return out } // checkSettingRichEditors refuses a submitted richeditor value of a settings // form that is neither a string nor null, before anything is written. func checkSettingRichEditors(setting *CompiledSetting, body map[string]any) error { for _, field := range setting.Form.Fields { if field.Type != richEditorType { continue } value, present := body[field.Name] if !present { continue } switch value.(type) { case nil, string: default: return &ValidationError{Details: fillTypeDetails(field.Name)} } } return nil } // sanitizeSettingRichEditors runs every projected richeditor value of a // settings form through seaglass, the gate the controller save uses, and // writes the clean value back; what the policy removed comes back as // notices on the field. func sanitizeSettingRichEditors(setting *CompiledSetting, projected map[string]any) []pluginNotice { var notices []pluginNotice for _, field := range setting.Form.Fields { if field.Type != richEditorType { continue } key := field.Name for _, w := range setting.Writable { if w.Name == field.Name { key = w.FillKey } } text, ok := projected[key].(string) if !ok { continue } clean, report := seaglass.SanitizeReport(text) projected[key] = clean for _, notice := range richEditorNotices(report) { notices = append(notices, pluginNotice{field: field.Name, notice: notice}) } } return notices } // settingsMeta builds the result meta, nil when there is nothing to report. func settingsMeta(ctx context.Context, tr *phrasebook.Translator, secrets map[string]string, notices []pluginNotice) *SettingsMeta { localized := localizeNotices(ctx, tr, notices) if len(secrets) == 0 && len(localized) == 0 { return nil } return &SettingsMeta{Secrets: secrets, Notices: localized} }