feat(18-01): write-only settings secrets, richeditor and tabs on settings forms (task 3)

- type: password on a settings form binds a lagoon.Encrypted column as a
  write-only secret (any other column fails boot naming the field): the
  GET omits it from data and reports stored, env or empty per field in
  meta.secrets; on PUT an absent key or "" keeps the value, null clears
  it, a non-string is a 422; the answer never carries a value
- cabana.SecretFallbacks (field to config key) gives the env state;
  cabana.SettingsMeta, SecretStored/SecretEnv/SecretEmpty exported
- settings forms accept richeditor: the Put sanitizes through seaglass
  like a controller save and returns meta.notices; repeater and
  dependsOn stay refused (settingsFormOnly next to controllerFormOnly)
- backend::lang secret.* phrases in en and pl
- admin SPA: PasswordField secret status line (stored with Remove stored
  value, env, empty, pending clear with Undo), SettingsFormView tabs with
  error-tab activation and the secrets/notices meta, settings payload
  that never sends an untouched secret; regenerated OpenAPI and TS
  types; rebuilt dist
- vault fixture, TestSettingsSecretCompile, Docker TestSettingsSecretSmoke
  and TestSettingsRichEditorSmoke, conform settings fixture with a
  password field; settings.md sections, forms.md notes, cabana README
This commit is contained in:
Jakub Zych
2026-10-13 11:36:12 +02:00
parent 0c8605123e
commit 1e841b4281
27 changed files with 1251 additions and 92 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-B8hF3DCG.js"></script>
<script type="module" crossorigin src="./assets/index-Dioh6ysQ.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-c6AgbZf1.css">
</head>
<body>

View File

@@ -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 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 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).
@@ -258,7 +258,10 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.AdminRecordAction` | Swag annotation of the record action route. |
| `cabana.ExecuteList` | Runs an allowlisted, paginated list query for a controller. |
| `cabana.RelationService` | Linked, candidate, link and unlink operations of relation managers, child create, show, update and delete (`CreateChild`, `ShowChild`, `UpdateChild`, `DeleteChildren`) and pivot values (`ShowPivot`, `UpdatePivot`); its `SessionKey` makes record id 0 the record being created in that session. |
| `cabana.SettingsService` | Reads and transactionally updates singleton settings rows; its `Tr` translator localizes the Put's money 422 messages. |
| `cabana.SettingsService` | Reads and transactionally updates singleton settings rows; its `Tr` translator localizes the Put's 422 messages and notices and its `Config` resolves the secret fallbacks. |
| `cabana.SettingsMeta` | Meta of a settings result: `secrets` (field to `stored`, `env` or `empty`) and the richeditor `notices`; omitted when empty. |
| `cabana.SecretFallbacks` | Implemented by a settings model whose password fields fall back to a config key when the column is empty: field name to compass key. |
| `cabana.SecretStored` / `cabana.SecretEnv` / `cabana.SecretEmpty` | The states of a write-only settings secret. |
| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. `WritableForeignKey` on a belongsTo contract makes a protected foreign key writable through that field. |
| `cabana.RelationLock` | The related ids of one relation field the requesting administrator may not add or remove, and the message of the 403. |
| `cabana.RelationLockProvider` | Controller capability that returns the `cabana.RelationLock` of a relation field per request; enforced on create and update. |

View File

@@ -363,6 +363,7 @@ func AdminSettingsSchema() {}
// AdminSettingsGet documents GET /settings/{code}.
//
// @Summary Read admin settings
// @Description The singleton's values keyed by field name. A write-only password field (a lagoon.Encrypted column) is never in data; meta.secrets reports its state instead: stored, env (empty column, the model's SecretFallbacks config key resolves) or empty. meta is omitted when the form has no password field.
// @Tags admin
// @Produce json
// @Security BackendBearer
@@ -377,6 +378,7 @@ func AdminSettingsGet() {}
// AdminSettingsPut documents PUT /settings/{code}.
//
// @Summary Update admin settings
// @Description Saves the singleton. For a password field an absent key or an empty string keeps the stored value and JSON null clears it; any other non-string value is a 422 on the field. A richeditor value is sanitized like a controller save and what was removed comes back in meta.notices. The answer carries meta.secrets with the new states and never the values.
// @Tags admin
// @Accept json
// @Produce json

View File

@@ -344,7 +344,8 @@ func TestDependentRecheck(t *testing.T) {
// TestRichEditor pins D-10 through the routes: a save stores the sanitized
// value with notices in the request locale, clean HTML byte for byte with
// none, null stays null, a value that is not a string is a 422 that writes
// nothing; and settings and relation forms refuse the type at boot.
// nothing; relation forms refuse the type at boot and settings forms accept
// it (Phase 18 F3).
func TestRichEditor(t *testing.T) {
t.Run("boot refusals", func(t *testing.T) {
for name, field := range map[string]string{
@@ -360,9 +361,16 @@ func TestRichEditor(t *testing.T) {
Code: "companions_settings", Label: "Settings", Category: "Acme", Form: "models/settings/fields.yaml", Model: "Profile",
NewModel: func() any { return &companionsProfile{} }, Permissions: []string{"acme.companions.access"},
}}})
if err == nil || !strings.Contains(err.Error(), "setting companions_settings field body: "+name+" is supported in controller forms only") {
// Phase 18 F3: a settings form lets a richeditor through its
// gate (the fixture then fails on the missing column, not on
// the type); a repeater is still refused.
refused := err != nil && strings.Contains(err.Error(), "setting companions_settings field body: "+name+" is supported in controller forms only")
if name == "repeater" && !refused {
t.Fatalf("settings form with %s: err = %v", name, err)
}
if name == "richeditor" && (refused || err == nil || !strings.Contains(err.Error(), "field body is not a model column")) {
t.Fatalf("settings form with richeditor should pass the gate: err = %v", err)
}
}
})

View File

@@ -116,11 +116,23 @@ func requireDependentProvider(ctl pact.AdminController, fields []FormField) erro
return nil
}
// controllerFormOnly refuses, on a settings form or a relation form, a field
// that only an admin controller's save path supports: its checks run in the
// controller save and nowhere else.
// controllerFormOnly refuses, on a relation form, a field that only an admin
// controller's save path supports: its checks run in the controller save and
// nowhere else. A settings form uses settingsFormOnly, which lets a
// richeditor through because the settings Put sanitizes it.
func controllerFormOnly(field FormField) error {
if field.Type == richEditorType || field.Type == repeaterType {
if field.Type == richEditorType {
return fmt.Errorf("%s is supported in controller forms only", field.Type)
}
return settingsFormOnly(field)
}
// settingsFormOnly refuses, on a settings form, a field only an admin
// controller's save path supports: a repeater and a dependent dropdown. A
// richeditor is let through, because the settings Put runs the same
// sanitizer gate as the controller save.
func settingsFormOnly(field FormField) error {
if field.Type == repeaterType {
return fmt.Errorf("%s is supported in controller forms only", field.Type)
}
if field.DependsOn != "" {

View File

@@ -17,6 +17,7 @@ import (
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/boardwalk"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
@@ -527,7 +528,7 @@ func (s *service) settingsGet(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
result, err := (SettingsService{DB: db, Tr: s.translator()}).Get(r.Context(), setting)
result, err := (SettingsService{DB: db, Tr: s.translator(), Config: s.config()}).Get(r.Context(), setting)
if err != nil {
writeCRUDError(w, err)
return
@@ -548,7 +549,7 @@ func (s *service) settingsPut(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
result, err := (SettingsService{DB: db, Tr: s.translator()}).Put(r.Context(), setting, body)
result, err := (SettingsService{DB: db, Tr: s.translator(), Config: s.config()}).Put(r.Context(), setting, body)
if err != nil {
writeCRUDError(w, err)
return
@@ -836,6 +837,14 @@ func (s *service) listSchema(w http.ResponseWriter, r *http.Request) {
})
}
// config is the app configuration, nil without an app.
func (s *service) config() *compass.Config {
if s == nil || s.app == nil {
return nil
}
return s.app.Config
}
func (s *service) translator() *phrasebook.Translator {
if s == nil || s.app == nil {
return nil

View File

@@ -353,7 +353,17 @@ func conformCases() []conformCase {
return e.send(t, http.MethodGet, "/settings/conform/schema", nil, true)
}, into[cabana.Envelope[cabana.FormView]](), nil},
{"GET /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/settings/conform", nil, true)
rec := e.send(t, http.MethodGet, "/settings/conform", nil, true)
// Phase 18 D-13: the password field is absent from data and
// reported in meta.secrets.
var body cabana.Envelope[cabana.SettingsResult]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if _, leaked := body.Data.Data["api_key"]; leaked || body.Data.Meta == nil || body.Data.Meta.Secrets["api_key"] != cabana.SecretEmpty {
t.Fatalf("settings secrets = %s", rec.Body.String())
}
return rec
}, into[cabana.Envelope[cabana.SettingsResult]](), nil},
{"PUT /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPut, "/settings/conform", map[string]any{"enabled": true}, true)
@@ -1060,12 +1070,13 @@ func (conformPart) AttachRelations() []attach.Relation {
}
type conformSettings struct {
ID uint `gorm:"column:id;primaryKey"`
Enabled bool `gorm:"column:enabled"`
ID uint `gorm:"column:id;primaryKey"`
Enabled bool `gorm:"column:enabled"`
APIKey lagoon.Encrypted `gorm:"column:api_key;type:text"`
}
func (conformSettings) TableName() string { return "cabana_conform_settings" }
func (conformSettings) Fillable() []string { return []string{"enabled"} }
func (conformSettings) Fillable() []string { return []string{"enabled", "api_key"} }
func (conformSettings) Rules() map[string]string { return map[string]string{} }
type conformPlugin struct{ stamp string }
@@ -1438,6 +1449,9 @@ parts:
label: Enabled
type: switch
default: 0
api_key:
label: API key
type: password
`),
}
}

View File

@@ -1,12 +1,15 @@
package cabana_test
import (
"bytes"
"context"
"database/sql/driver"
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -89,7 +92,7 @@ func (paymentsInvoice) Rules() map[string]string { return map[string]string{"num
// paymentsModels are the fixture tables, dropped and recreated per env.
func paymentsModels() []any {
return []any{&paymentsInvoice{}}
return []any{&paymentsInvoice{}, &paymentsVault{}}
}
// paymentsPlugin registers the invoices controller.
@@ -107,6 +110,38 @@ func (paymentsPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.payments.access", Roles: []string{"developer"}}}
}
// Settings registers the vault page: two write-only secrets on two tabs
// and a richeditor on the default tab (D-13, F2 to F4).
func (paymentsPlugin) Settings() []pact.SettingsItem {
return []pact.SettingsItem{{Code: "vault", Label: "Vault", Category: "Acme", Icon: "settings", Model: "Vault",
Permissions: []string{"acme.payments.access"}, Form: "models/vault/fields.yaml",
NewModel: func() any { return &paymentsVault{} }}}
}
// paymentsVault is the settings model: two lagoon.Encrypted secrets, the
// first with a host-config fallback, and a richeditor notes column.
type paymentsVault struct {
ID uint `gorm:"column:id;primaryKey"`
APIKey lagoon.Encrypted `gorm:"column:api_key;type:text"`
TestAPIKey lagoon.Encrypted `gorm:"column:test_api_key;type:text"`
Notes string `gorm:"column:notes"`
}
func (paymentsVault) TableName() string { return "cabana_payments_vault" }
func (paymentsVault) Fillable() []string { return []string{"api_key", "test_api_key", "notes"} }
func (paymentsVault) Rules() map[string]string { return map[string]string{} }
// SecretFallbacks maps api_key to the acme.vault.api_key config key
// (SUMMER_ACME__VAULT__API_KEY).
func (paymentsVault) SecretFallbacks() map[string]string {
return map[string]string{"api_key": "acme.vault.api_key"}
}
var _ cabana.SecretFallbacks = paymentsVault{}
// paymentsAppKey is the SUMMER_APP__KEY of the fixture: 32 bytes, base64.
var paymentsAppKey = base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("P"), 32))
// paymentsInvoices is the invoices controller.
type paymentsInvoices struct{}
@@ -132,13 +167,14 @@ var _ pact.HasAdminRecordActions = paymentsInvoices{}
// paymentsAssemble boots plugins on the test database with the fixture's
// config: the assembled admin handler, or the boot error.
func paymentsAssemble(t *testing.T, gdb *gorm.DB, plugins []party.Plugin) (http.Handler, error) {
func paymentsAssemble(t *testing.T, gdb *gorm.DB, plugins []party.Plugin, environ ...string) (http.Handler, error) {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-payments\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
environ = append([]string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret, "SUMMER_APP__KEY=" + paymentsAppKey}, environ...)
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: environ})
if err != nil {
t.Fatal(err)
}
@@ -151,6 +187,15 @@ func paymentsAssemble(t *testing.T, gdb *gorm.DB, plugins []party.Plugin) (http.
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
t.Fatal(err)
}
// The column-encryption keys of the vault secrets (OpenFromApp does this
// at boot; the test publishes the shared test database by hand).
primary, previous, err := lagoon.LoadAppKey(app.Config)
if err != nil {
t.Fatal(err)
}
if err := lagoon.PublishEncryptionKeys(app, primary, previous); err != nil {
t.Fatal(err)
}
if err := phrasebook.Activate(app, plugins); err != nil {
t.Fatal(err)
}
@@ -579,3 +624,190 @@ func TestRecordActionDangerSmoke(t *testing.T) {
t.Fatalf("raw actions = %s %s (danger true on void, the key absent on resend)", out.Meta.Actions[0], out.Meta.Actions[1])
}
}
// vaultResult is a settings answer of the vault page.
type vaultResult struct {
Data struct {
Exists bool `json:"exists"`
Data map[string]any `json:"data"`
Meta *struct {
Secrets map[string]string `json:"secrets"`
Notices []cabana.FieldNotice `json:"notices"`
} `json:"meta"`
} `json:"data"`
}
func decodeVault(t *testing.T, raw []byte) vaultResult {
t.Helper()
var out vaultResult
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("vault: %v\n%s", err, raw)
}
return out
}
// TestSettingsSecretSmoke checks the D-13 write-only contract on Postgres:
// the GET never carries a secret and reports its state; a PUT with a value
// stores ciphertext; an empty string keeps the stored value; null clears it;
// a non-string is a 422; an empty column with the fallback key set in the
// environment reports env; and no body ever contains the plaintext.
func TestSettingsSecretSmoke(t *testing.T) {
e := newPaymentsEnv(t)
const secret = "sk_1"
noLeak := func(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if strings.Contains(rec.Body.String(), secret) || strings.Contains(rec.Body.String(), "sk_env") {
t.Fatalf("body leaks the secret: %s", rec.Body.String())
}
}
states := func(t *testing.T, rec *httptest.ResponseRecorder) map[string]string {
t.Helper()
out := decodeVault(t, rec.Body.Bytes())
for _, name := range []string{"api_key", "test_api_key"} {
if _, leaked := out.Data.Data[name]; leaked {
t.Fatalf("%s is in data: %s", name, rec.Body.String())
}
}
if out.Data.Meta == nil {
t.Fatalf("no meta: %s", rec.Body.String())
}
return out.Data.Meta.Secrets
}
rec := e.send(t, http.MethodGet, "/settings/vault", nil, true)
if rec.Code != http.StatusOK {
t.Fatalf("get status=%d body=%s", rec.Code, rec.Body.String())
}
if got := states(t, rec); got["api_key"] != cabana.SecretEmpty || got["test_api_key"] != cabana.SecretEmpty {
t.Fatalf("initial states = %v", got)
}
if _, has := decodeVault(t, rec.Body.Bytes()).Data.Data["notes"]; !has {
t.Fatalf("notes missing from data: %s", rec.Body.String())
}
rec = e.send(t, http.MethodPut, "/settings/vault", map[string]any{"api_key": secret}, true)
if rec.Code != http.StatusOK {
t.Fatalf("put status=%d body=%s", rec.Code, rec.Body.String())
}
noLeak(t, rec)
if got := states(t, rec); got["api_key"] != cabana.SecretStored || got["test_api_key"] != cabana.SecretEmpty {
t.Fatalf("states after put = %v", got)
}
var column struct{ APIKey *string }
if err := e.db.Raw("SELECT api_key FROM cabana_payments_vault WHERE id = 1").Scan(&column).Error; err != nil {
t.Fatal(err)
}
if column.APIKey == nil || *column.APIKey == "" || *column.APIKey == secret {
t.Fatalf("stored api_key = %v, want non-empty ciphertext", column.APIKey)
}
ciphertext := *column.APIKey
rec = e.send(t, http.MethodGet, "/settings/vault", nil, true)
noLeak(t, rec)
if got := states(t, rec); got["api_key"] != cabana.SecretStored {
t.Fatalf("states after get = %v", got)
}
// An empty string keeps the stored value, byte for byte.
rec = e.send(t, http.MethodPut, "/settings/vault", map[string]any{"api_key": "", "test_api_key": ""}, true)
if rec.Code != http.StatusOK {
t.Fatalf("keep status=%d body=%s", rec.Code, rec.Body.String())
}
noLeak(t, rec)
if got := states(t, rec); got["api_key"] != cabana.SecretStored || got["test_api_key"] != cabana.SecretEmpty {
t.Fatalf("states after keep = %v", got)
}
if err := e.db.Raw("SELECT api_key FROM cabana_payments_vault WHERE id = 1").Scan(&column).Error; err != nil {
t.Fatal(err)
}
if column.APIKey == nil || *column.APIKey != ciphertext {
t.Fatalf("an empty string rewrote the stored secret")
}
// A non-string is a 422 on the field, and nothing is written.
rec = e.send(t, http.MethodPut, "/settings/vault", map[string]any{"api_key": 5}, true)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"api_key"`) {
t.Fatalf("non-string status=%d body=%s", rec.Code, rec.Body.String())
}
noLeak(t, rec)
// null clears.
rec = e.send(t, http.MethodPut, "/settings/vault", map[string]any{"api_key": nil}, true)
if rec.Code != http.StatusOK {
t.Fatalf("clear status=%d body=%s", rec.Code, rec.Body.String())
}
if got := states(t, rec); got["api_key"] != cabana.SecretEmpty {
t.Fatalf("states after clear = %v", got)
}
if err := e.db.Raw("SELECT api_key FROM cabana_payments_vault WHERE id = 1").Scan(&column).Error; err != nil {
t.Fatal(err)
}
if column.APIKey != nil && *column.APIKey != "" {
t.Fatalf("null did not clear the column: %q", *column.APIKey)
}
// With the fallback key set and the column empty, the state is env.
h, err := paymentsAssemble(t, e.db, []party.Plugin{paymentsPlugin{}}, "SUMMER_ACME__VAULT__API_KEY=sk_env")
if err != nil {
t.Fatal(err)
}
env := &conformEnv{h: h, db: e.db, login: e.login}
if rec := env.send(t, http.MethodPost, "/auth/login", map[string]string{"login": e.login, "password": adminTestPassword}, false); rec.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
}
rec = env.send(t, http.MethodGet, "/settings/vault", nil, true)
if rec.Code != http.StatusOK {
t.Fatalf("env get status=%d body=%s", rec.Code, rec.Body.String())
}
noLeak(t, rec)
if got := states(t, rec); got["api_key"] != cabana.SecretEnv || got["test_api_key"] != cabana.SecretEmpty {
t.Fatalf("env states = %v", got)
}
}
// TestSettingsRichEditorSmoke checks F3 on Postgres: a settings richeditor
// value is stored sanitized with notices, clean HTML byte for byte with no
// notice, and a value that is not a string is a 422 on the field.
func TestSettingsRichEditorSmoke(t *testing.T) {
e := newPaymentsEnv(t)
dirty := `<p onclick="steal()">Bank</p><script>alert(1)</script>`
rec := e.send(t, http.MethodPut, "/settings/vault", map[string]any{"notes": dirty}, true)
if rec.Code != http.StatusOK {
t.Fatalf("put status=%d body=%s", rec.Code, rec.Body.String())
}
out := decodeVault(t, rec.Body.Bytes())
if out.Data.Data["notes"] != "<p>Bank</p>" {
t.Fatalf("notes = %#v", out.Data.Data["notes"])
}
if out.Data.Meta == nil || len(out.Data.Meta.Notices) != 2 || out.Data.Meta.Notices[0].Field != "notes" {
t.Fatalf("notices = %s", rec.Body.String())
}
var stored paymentsVault
if err := e.db.First(&stored, 1).Error; err != nil {
t.Fatal(err)
}
if stored.Notes != "<p>Bank</p>" {
t.Fatalf("stored notes = %q", stored.Notes)
}
clean := "<p>IBAN <strong>PL00</strong> &amp; \"swift\"</p>\n<ul><li>one</li></ul>"
rec = e.send(t, http.MethodPut, "/settings/vault", map[string]any{"notes": clean}, true)
if rec.Code != http.StatusOK {
t.Fatalf("clean status=%d body=%s", rec.Code, rec.Body.String())
}
if out := decodeVault(t, rec.Body.Bytes()); out.Data.Meta != nil && len(out.Data.Meta.Notices) != 0 {
t.Fatalf("clean HTML reported notices: %s", rec.Body.String())
}
var again paymentsVault
if err := e.db.First(&again, 1).Error; err != nil {
t.Fatal(err)
}
if again.Notes != clean {
t.Fatalf("clean notes not stored byte for byte: %q", again.Notes)
}
rec = e.send(t, http.MethodPut, "/settings/vault", map[string]any{"notes": []any{"<p>x</p>"}}, true)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"notes"`) {
t.Fatalf("non-string notes status=%d body=%s", rec.Code, rec.Body.String())
}
}

View File

@@ -95,11 +95,18 @@ func TestRichEditorCompile(t *testing.T) {
}
})
t.Run("settings forms accept a richeditor (Phase 18 F3)", func(t *testing.T) {
fsys := fstest.MapFS{"models/settings/fields.yaml": &fstest.MapFile{Data: []byte("fields:\n enabled:\n type: switch\n extra:\n type: richeditor\n")}}
item := pact.SettingsItem{Code: "demo", Label: "Demo", Form: "models/settings/fields.yaml", Model: "DemoSettings", NewModel: func() any { return &ucSettings{} }}
if _, err := compileSetting("acme.unit", item, fsys); err != nil {
t.Fatalf("richeditor refused on a settings form: %v", err)
}
})
t.Run("settings forms refuse the controller-only features", func(t *testing.T) {
for name, field := range map[string]string{
"richeditor": " type: richeditor\n",
"repeater": " type: repeater\n form:\n fields:\n a:\n type: text\n",
"dependsOn": " type: dropdown\n dependsOn: enabled\n",
"repeater": " type: repeater\n form:\n fields:\n a:\n type: text\n",
"dependsOn": " type: dropdown\n dependsOn: enabled\n",
} {
fsys := fstest.MapFS{"models/settings/fields.yaml": &fstest.MapFile{Data: []byte("fields:\n enabled:\n type: switch\n extra:\n" + field)}}
item := pact.SettingsItem{Code: "demo", Label: "Demo", Form: "models/settings/fields.yaml", Model: "DemoSettings", NewModel: func() any { return &ucSettings{} }}

View File

@@ -10,6 +10,7 @@ import (
"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"
@@ -25,20 +26,29 @@ type CompiledSetting struct {
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.
// 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 of the settings Put (a money value outside
// its field's scale or max); nil keeps the phrase keys.
// 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
DB *gorm.DB
Tr *phrasebook.Translator
Config *compass.Config
}
// compileSettingLink registers a settings entry that opens an admin
@@ -89,13 +99,13 @@ func compileSettingWithTypes(pluginID string, item pact.SettingsItem, fsys fs.FS
}
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 == "password" || field.Type == permissionFieldType {
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 := controllerFormOnly(field); err != nil {
if err := settingsFormOnly(field); err != nil {
return nil, fmt.Errorf("cabana: setting %s field %s: %w", item.Code, field.Name, err)
}
}
@@ -106,8 +116,12 @@ func compileSettingWithTypes(pluginID string, item pact.SettingsItem, fsys fs.FS
fillable[key] = struct{}{}
}
writable := make([]WritableField, 0, len(fields))
var secrets []string
for _, field := range fields {
if !scalarFormField(field.Type) || protectedFillKey(field.Name) {
// 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 {
@@ -126,9 +140,15 @@ func compileSettingWithTypes(pluginID string, item pact.SettingsItem, fsys fs.FS
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}, nil
return &CompiledSetting{PluginID: pluginID, Item: item, Form: form, Writable: writable, Secrets: secrets}, nil
}
// Get returns default-valued data without creating a missing singleton.
@@ -144,13 +164,15 @@ func (s SettingsService) Get(ctx context.Context, setting *CompiledSetting) (Set
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 {
exists := err == nil
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return SettingsResult{}, err
}
return SettingsResult{Exists: true, Data: projectSetting(setting, model)}, nil
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
@@ -165,7 +187,19 @@ func (s SettingsService) Put(ctx context.Context, setting *CompiledSetting, body
if details := moneyDetails(ctx, s.Tr, setting.Form.Fields, body, nil); len(details) > 0 {
return SettingsResult{}, &ValidationError{Details: details}
}
var result SettingsResult
// 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 {
@@ -187,6 +221,9 @@ func (s SettingsService) Put(ctx context.Context, setting *CompiledSetting, body
}
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)
@@ -217,7 +254,11 @@ func (s SettingsService) Put(ctx context.Context, setting *CompiledSetting, body
return err
}
}
result = SettingsResult{Exists: true, Data: after}
result = SettingsResult{
Exists: true,
Data: withoutSecrets(setting, after),
Meta: settingsMeta(ctx, s.Tr, secretStates(setting, model, s.Config), notices),
}
return nil
})
return result, err
@@ -309,7 +350,8 @@ func requireSettingInputs(setting *CompiledSetting, body map[string]any) error {
details[field.Name] = []string{"The " + field.Name + " field is invalid."}
continue
}
if field.Required && !present {
// 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."}
}
}

View File

@@ -0,0 +1,212 @@
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}
}

View File

@@ -0,0 +1,99 @@
package cabana
import (
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
)
// secretSettings is a settings model with one lagoon.Encrypted column and
// one plain string column, for the write-only password compile gate.
type secretSettings struct {
ID uint `gorm:"column:id;primaryKey"`
APIKey lagoon.Encrypted `gorm:"column:api_key;type:text"`
Token string `gorm:"column:token"`
Notes string `gorm:"column:notes"`
}
func (secretSettings) TableName() string { return "cabana_secret_settings" }
func (secretSettings) Fillable() []string { return []string{"api_key", "token", "notes"} }
func (secretSettings) Rules() map[string]string { return map[string]string{} }
// SecretFallbacks maps each secret field to the config key the application
// reads when the column is empty; SUMMER_* variables map onto those keys.
func (secretSettings) SecretFallbacks() map[string]string {
return map[string]string{"api_key": "acme.secret.api_key"}
}
func secretSettingsItem(fields string) (pact.SettingsItem, fstest.MapFS) {
fsys := fstest.MapFS{"models/settings/fields.yaml": &fstest.MapFile{Data: []byte(fields)}}
item := pact.SettingsItem{Code: "secret", Label: "Secret", Form: "models/settings/fields.yaml", Model: "SecretSettings", NewModel: func() any { return &secretSettings{} }}
return item, fsys
}
// TestSettingsSecretCompile checks the D-13 boot gate without a database: a
// password field on a settings form binds a lagoon.Encrypted column as a
// write-only secret, keeps its tab, and is refused on any other column type
// naming the field.
func TestSettingsSecretCompile(t *testing.T) {
item, fsys := secretSettingsItem("fields:\n api_key:\n label: API key\n type: password\n tab: Keys\n notes:\n type: richeditor\n")
setting, err := compileSetting("acme.unit", item, fsys)
if err != nil {
t.Fatal(err)
}
if len(setting.Secrets) != 1 || setting.Secrets[0] != "api_key" {
t.Fatalf("secrets = %v", setting.Secrets)
}
names := map[string]bool{}
for _, w := range setting.Writable {
names[w.Name] = true
}
if !names["api_key"] || !names["notes"] {
t.Fatalf("writable = %+v", setting.Writable)
}
if setting.Form.Fields[0].Tab != "Keys" {
t.Fatalf("tab dropped: %+v", setting.Form.Fields[0])
}
data := withoutSecrets(setting, map[string]any{"api_key": lagoon.NewEncrypted("x"), "notes": "n"})
if _, leaked := data["api_key"]; leaked || data["notes"] != "n" {
t.Fatalf("projection = %#v", data)
}
if (secretSettings{}).SecretFallbacks()["api_key"] != "acme.secret.api_key" {
t.Fatal("fallback key")
}
states := secretStates(setting, &secretSettings{}, nil)
if states["api_key"] != SecretEmpty {
t.Fatalf("states = %v", states)
}
states = secretStates(setting, &secretSettings{APIKey: lagoon.NewEncrypted("sk")}, nil)
if states["api_key"] != SecretStored {
t.Fatalf("states = %v", states)
}
item, fsys = secretSettingsItem("fields:\n token:\n type: password\n")
_, err = compileSetting("acme.unit", item, fsys)
if err == nil || !strings.Contains(err.Error(), "field token: password on a settings form needs a lagoon.Encrypted column") {
t.Fatalf("password on a string column: %v", err)
}
body := map[string]any{"api_key": "", "notes": "n"}
if err := liftSecretValues(setting, body); err != nil {
t.Fatal(err)
}
if _, kept := body["api_key"]; kept {
t.Fatalf("empty string did not keep the stored value: %#v", body)
}
body = map[string]any{"api_key": nil}
if err := liftSecretValues(setting, body); err != nil {
t.Fatal(err)
}
if value, present := body["api_key"]; !present || value != nil {
t.Fatalf("null did not stay as the clear: %#v", body)
}
if err := liftSecretValues(setting, map[string]any{"api_key": 5}); err == nil {
t.Fatal("a non-string secret was accepted")
}
}

View File

@@ -0,0 +1,12 @@
fields:
api_key:
label: API key
type: password
tab: Live keys
test_api_key:
label: Test API key
type: password
tab: Test keys
notes:
label: Notes
type: richeditor

View File

@@ -144,6 +144,14 @@ money:
too_precise: "Use at most :scale decimal places. Amounts are never rounded."
too_large: "This amount is larger than the field can store (at most :max)."
currency: "Currency: :code"
secret:
stored: "A value is stored. Leave the field empty to keep it."
env: "Nothing is stored here, so the value from the server configuration is used."
empty: "Not set."
clear: "Remove stored value"
clear_pending: "The stored value will be removed when you save."
undo: "Keep stored value"
placeholder_stored: "Enter a new value to replace it"
fileupload:
attachment: Attachment details
help: Add a title and description for this attachment.

View File

@@ -154,6 +154,14 @@ money:
too_precise: "Użyj najwyżej :scale miejsc po przecinku. Kwoty nie są zaokrąglane."
too_large: "Ta kwota jest większa, niż pole może zapisać (najwyżej :max)."
currency: "Waluta: :code"
secret:
stored: "Wartość jest zapisana. Pozostaw pole puste, aby jej nie zmieniać."
env: "Nic tu nie zapisano, więc używana jest wartość z konfiguracji serwera."
empty: "Nie ustawiono."
clear: "Usuń zapisaną wartość"
clear_pending: "Zapisana wartość zostanie usunięta po zapisaniu."
undo: "Zachowaj zapisaną wartość"
placeholder_stored: "Wpisz nową wartość, aby ją zastąpić"
fileupload:
attachment: Szczegóły załącznika
help: Dodaj tytuł oraz opis załącznika.