diff --git a/docs/backend/settings.md b/docs/backend/settings.md index 48d2428..9f3d7bf 100644 --- a/docs/backend/settings.md +++ b/docs/backend/settings.md @@ -62,6 +62,8 @@ fields: span: right ``` +A dropdown whose `options` names a method (`options: getCurrencyOptions`) takes its choices from the settings model, as a WinterCMS settings model's `get*Options` method did. The model implements `pact.DropdownOptionsProvider`; `DropdownOptions` receives the field name and returns `pact.Option` values whose labels may be phrase keys. A settings model without it stops the start-up naming the field. + Create the table in a migration like any other; see [Migrations](../database/migrations.md). ## Linking a settings entry to an admin controller diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 1dc965f..dad6359 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -50,7 +50,7 @@ Use cases: - File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation. - Refusals: a lifecycle hook, a relation hook or a bulk, record, toolbar or widget action that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. The surrounding transaction is rolled back. Every other error that is not a `cabana.ValidationError` stays the opaque 500, logged on the server. - Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant. -- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code. +- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A `type: dropdown` whose `options` names a method takes its choices from the settings model, which must implement `pact.DropdownOptionsProvider` (boot fails naming the field otherwise); the settings schema serves them localized. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code. - Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full. - Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a composite `backend` guard (a guard another plugin already registered under that name fails `cabana.Activate`) that accepts an `Authorization: Bearer` personal API token (`cabana.APITokenPrefix` / `g15_…`) or, otherwise, the unchanged admin JWT path (Bearer JWT, then the admin cookie). Personal tokens never come from a cookie or the query string. Login throttling, token refresh and revocation, and the SPA cookie transport are unchanged. Cookie-authenticated requests that change state must carry `X-Requested-With: XMLHttpRequest`, which blocks cross-site request forgery. - Personal API tokens: administrators mint scoped tokens stored in `backend_api_tokens` (`cabana.BackendAPIToken`); only the sha256 hash and a four-character hint are kept. Framework scopes are `*` (full access as the owner on every method) and `admin:read` (as the owner on GET and HEAD); plugins add scopes through `pact.HasTokenScopes`. Interactive-only routes (account profile, avatar, token management, refresh and logout) refuse every personal token with `details.reason` `interactive_only`. Routes outside `cabana.Allows` (`GET /field-types`, `POST /markdown/preview`) are gated so only a full scope, or `admin:read` on GET, passes. Plugin routes resolve a backend principal with `cabana.ResolveBackend` (Bearer only; never the admin cookie). diff --git a/modules/cabana/http.go b/modules/cabana/http.go index 0f5cb4f..74bb34e 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -512,7 +512,7 @@ func (s *service) settingsList(w http.ResponseWriter, r *http.Request) { func (s *service) settingsSchema(w http.ResponseWriter, r *http.Request) { s.protectSetting(w, r, func(setting *CompiledSetting) { - view, err := setting.Form.Localize(r.Context(), s.translator(), nil) + view, err := setting.Form.Localize(r.Context(), s.translator(), settingDropdownProvider(setting)) if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return diff --git a/modules/cabana/settings.go b/modules/cabana/settings.go index 5fbf407..4d08c8f 100644 --- a/modules/cabana/settings.go +++ b/modules/cabana/settings.go @@ -108,6 +108,11 @@ func compileSettingWithTypes(pluginID string, item pact.SettingsItem, fsys fs.FS 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) @@ -151,6 +156,16 @@ func compileSettingWithTypes(pluginID string, item pact.SettingsItem, fsys fs.FS 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 { diff --git a/modules/cabana/settings_options_test.go b/modules/cabana/settings_options_test.go new file mode 100644 index 0000000..141ef69 --- /dev/null +++ b/modules/cabana/settings_options_test.go @@ -0,0 +1,64 @@ +package cabana + +import ( + "context" + "strings" + "testing" + "testing/fstest" + + "git.golem15.com/golem15/summercms/modules/pact" +) + +// optionSettings is a settings model whose dropdown options come from a +// model method, like a WinterCMS settings model's get*Options. +type optionSettings struct { + ID uint `gorm:"column:id;primaryKey"` + Currency string `gorm:"column:currency"` +} + +func (optionSettings) TableName() string { return "cabana_option_settings" } +func (optionSettings) Fillable() []string { return []string{"currency"} } +func (optionSettings) Rules() map[string]string { return map[string]string{} } + +func (optionSettings) DropdownOptions(field string) []pact.Option { + if field != "currency" { + return nil + } + return []pact.Option{{Value: "EUR", Label: "EUR — Euro"}, {Value: "PLN", Label: "PLN — Polish zloty"}} +} + +// plainSettings has the same column and no DropdownOptions. +type plainSettings struct { + ID uint `gorm:"column:id;primaryKey"` + Currency string `gorm:"column:currency"` +} + +func (plainSettings) TableName() string { return "cabana_plain_settings" } +func (plainSettings) Fillable() []string { return []string{"currency"} } +func (plainSettings) Rules() map[string]string { return map[string]string{} } + +// TestSettingsDropdownMethod checks that a settings form takes a +// method-backed dropdown's options from its settings model, and that boot +// refuses the method when the model has no DropdownOptions. +func TestSettingsDropdownMethod(t *testing.T) { + fsys := fstest.MapFS{"models/settings/fields.yaml": &fstest.MapFile{Data: []byte("fields:\n currency:\n label: Currency\n type: dropdown\n options: getCurrencyOptions\n")}} + item := pact.SettingsItem{Code: "options", Label: "Options", Form: "models/settings/fields.yaml", NewModel: func() any { return &optionSettings{} }} + setting, err := compileSetting("acme.unit", item, fsys) + if err != nil { + t.Fatal(err) + } + view, err := setting.Form.Localize(context.Background(), nil, settingDropdownProvider(setting)) + if err != nil { + t.Fatal(err) + } + opts := view.Fields[0].Options + if len(opts) != 2 || opts[1].Label != "PLN — Polish zloty" { + t.Fatalf("options = %+v", opts) + } + + item.NewModel = func() any { return &plainSettings{} } + _, err = compileSetting("acme.unit", item, fsys) + if err == nil || !strings.Contains(err.Error(), "dropdown method getCurrencyOptions requires DropdownOptions on the settings model") { + t.Fatalf("err = %v", err) + } +}