feat(cabana): settings entries that link to an admin controller

pact.SettingsItem gains an additive Controller field, the equivalent of a
WinterCMS registerSettings 'url' => Backend::url(...) entry. A link entry
declares no Model, Form or NewModel and needs no AdminFS; start-up fails
when it combines Controller with a singleton form or a model, or names an
unregistered controller. Settings codes stay one namespace.

GET /settings lists a link entry with its controller only when the
principal passes the item's permissions and may open the controller.
Registry.Setting never returns a link entry, so the singleton settings
endpoints answer 404 for its code. SettingsEntry carries controller,
empty for singletons; the OpenAPI document, generated SPA types and
settings fixture follow, and the pact and cabana READMEs and the
settings docs describe the link.
This commit is contained in:
Jakub Zych
2026-10-07 18:22:01 +02:00
parent b040ee3a74
commit 4a9b0896b1
15 changed files with 338 additions and 14 deletions

View File

@@ -1875,6 +1875,10 @@
"code": { "code": {
"type": "string" "type": "string"
}, },
"controller": {
"description": "Controller is the admin controller a link entry opens; empty for a\nsingleton settings form.",
"type": "string"
},
"description": { "description": {
"type": "string" "type": "string"
}, },
@@ -1900,6 +1904,7 @@
"required": [ "required": [
"category", "category",
"code", "code",
"controller",
"description", "description",
"icon", "icon",
"keywords", "keywords",

View File

@@ -4886,6 +4886,11 @@ export interface components {
"cabana.SettingsEntry": { "cabana.SettingsEntry": {
category: string; category: string;
code: string; code: string;
/**
* @description Controller is the admin controller a link entry opens; empty for a
* singleton settings form.
*/
controller: string;
description: string; description: string;
icon: string; icon: string;
keywords: string[]; keywords: string[];

View File

@@ -9,6 +9,7 @@
"category": "System", "category": "System",
"keywords": [], "keywords": [],
"model": "Acme\\Demo\\Models\\Settings", "model": "Acme\\Demo\\Models\\Settings",
"controller": "",
"order": 20 "order": 20
}, },
{ {
@@ -19,6 +20,7 @@
"category": "System", "category": "System",
"keywords": [], "keywords": [],
"model": "Acme\\Demo\\Models\\MailSettings", "model": "Acme\\Demo\\Models\\MailSettings",
"controller": "",
"order": 10 "order": 10
}, },
{ {
@@ -29,6 +31,7 @@
"category": "Appearance", "category": "Appearance",
"keywords": [], "keywords": [],
"model": "Acme\\Demo\\Models\\Branding", "model": "Acme\\Demo\\Models\\Branding",
"controller": "",
"order": 5 "order": 5
} }
], ],

View File

@@ -1,6 +1,6 @@
--- ---
title: Settings title: Settings
description: Declare singleton settings pages with pact.SettingsItem, backed by a model, a fields.yaml form and validation rules, and read them from plugin code. description: Declare settings pages with pact.SettingsItem, as singleton forms backed by a model or as links to admin controllers, and read settings from plugin code.
section: backend section: backend
order: 60 order: 60
--- ---
@@ -64,6 +64,27 @@ fields:
Create the table in a migration like any other; see [Migrations](../database/migrations.md). Create the table in a migration like any other; see [Migrations](../database/migrations.md).
## Linking a settings entry to an admin controller
A WinterCMS `registerSettings` entry can point at a backend controller instead of a settings model, with `'url' => Backend::url('acme/blog/posts')`. The entry appears on the Settings page and opens that controller's list. In SummerCMS the `Controller` field of `pact.SettingsItem` does the same: set it to the admin controller ID (`vendor.plugin.controller`) and leave `Model`, `Form` and `NewModel` empty. The plugin returns this entry from `Settings` next to, or instead of, its singleton pages:
```go src=modules/cabana/example_settings_test.go#settings-link
link := pact.SettingsItem{
Code: "posts",
Label: "acme.blog::lang.posts.title",
Description: "acme.blog::lang.posts.description",
Category: "acme.blog::lang.plugin.name",
Icon: "icon-pencil",
Order: 500,
Permissions: []string{"acme.blog.access_settings"},
Controller: "acme.blog.posts",
}
```
A link entry is not a singleton and needs no embedded admin tree. [cabana](../../modules/cabana/README.md) stops start-up when a link entry also declares `Model`, `Form` or `NewModel`, when it names a controller no plugin registered, or when its code repeats another settings code; singleton and link entries share one code namespace.
`GET .../settings` lists a link entry with its `controller` set (and `model` empty) only when the administrator passes the entry's permissions and may also open the controller, so a controller the administrator cannot reach is never advertised. Singleton entries carry an empty `controller`. The singleton endpoints (`.../settings/{code}`, `.../settings/{code}/schema`) answer 404 for a link entry's code.
## How values are stored ## How values are stored
The settings row is the one with ID 1. Before it exists, the page shows the fields' `default` values and the API reports that the row does not exist yet; the first save creates it. A save writes only fillable fields, validates them with the model's rules and the form's `required` flags, and runs in a transaction, as a controller form save does. The settings row is the one with ID 1. Before it exists, the page shows the fields' `default` values and the API reports that the row does not exist yet; the first save creates it. A save writes only fillable fields, validates them with the model's rules and the form's `required` flags, and runs in a transaction, as a controller form save does.

View File

@@ -35,7 +35,7 @@ Each row names the WinterCMS concept, the SummerCMS identifiers that replace it,
| Queued jobs | `pact.HasJobs` with jobs built by `conga.Job`, dispatched with `conga.Manager.Dispatch` inside the caller's transaction | [Queued jobs](../services/jobs.md), [conga](../../modules/conga/README.md) | | Queued jobs | `pact.HasJobs` with jobs built by `conga.Job`, dispatched with `conga.Manager.Dispatch` inside the caller's transaction | [Queued jobs](../services/jobs.md), [conga](../../modules/conga/README.md) |
| `registerSchedule` and the scheduler | `pact.HasSchedule` returning `pact.ScheduledCommand` entries with a `pact.Cadence` | [Task scheduling](../plugins/scheduling.md), [pact](../../modules/pact/README.md) | | `registerSchedule` and the scheduler | `pact.HasSchedule` returning `pact.ScheduledCommand` entries with a `pact.Cadence` | [Task scheduling](../plugins/scheduling.md), [pact](../../modules/pact/README.md) |
| Mail templates in `views/mail` | `pact.HasMailTemplates`, sent through `postcard.Mailer` | [Mail](../services/mail.md), [postcard](../../modules/postcard/README.md) | | Mail templates in `views/mail` | `pact.HasMailTemplates`, sent through `postcard.Mailer` | [Mail](../services/mail.md), [postcard](../../modules/postcard/README.md) |
| Settings models and `registerSettings` | `pact.HasSettings` returning `pact.SettingsItem` entries | [Settings](../backend/settings.md), [cabana](../../modules/cabana/README.md) | | Settings models and `registerSettings` | `pact.HasSettings` returning `pact.SettingsItem` entries; an entry with `'url' => Backend::url(...)` sets the `Controller` field to the admin controller ID | [Settings](../backend/settings.md), [cabana](../../modules/cabana/README.md) |
| Laravel broadcasting | `lighthouse.Publisher` drivers and models that implement `lighthouse.Broadcastable` | [Realtime](../services/realtime.md), [lighthouse](../../modules/lighthouse/README.md) | | Laravel broadcasting | `lighthouse.Publisher` drivers and models that implement `lighthouse.Broadcastable` | [Realtime](../services/realtime.md), [lighthouse](../../modules/lighthouse/README.md) |
| Laravel Scout search | Models that implement `beachcomber.Searchable`, synced after commit | [Search](../services/search.md), [beachcomber](../../modules/beachcomber/README.md) | | Laravel Scout search | Models that implement `beachcomber.Searchable`, synced after commit | [Search](../services/search.md), [beachcomber](../../modules/beachcomber/README.md) |
| The Laravel HTTP client | `fetchguard.Fetch` with a `fetchguard.Policy` that blocks private addresses and limits size and time | [Outbound HTTP](../services/outbound-http.md), [fetchguard](../../modules/fetchguard/README.md) | | The Laravel HTTP client | `fetchguard.Fetch` with a `fetchguard.Policy` that blocks private addresses and limits size and time | [Outbound HTTP](../services/outbound-http.md), [fetchguard](../../modules/fetchguard/README.md) |

View File

@@ -30,7 +30,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation. - Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
- 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. - 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. - 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.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. - 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.
- 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: 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). - 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: 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).
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery. - Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
- A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`. A body that cannot be encoded is logged and answered with the generic 500 envelope, never a success status with a truncated body. - A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`. A body that cannot be encoded is logged and answered with the generic 500 envelope, never a success status with a truncated body.
@@ -46,7 +46,7 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
| POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. | | POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. |
| GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. | | GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. |
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator. | | POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator. |
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open. | | GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. |
| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. | | GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. |
| POST `/markdown/preview` | Render `{markdown}` through `cabana.RenderMarkdown` for the form preview and answer `{html}`; a refused output is a 422 `validation_failed` on `markdown`. Needs any backend session. | | POST `/markdown/preview` | Render `{markdown}` through `cabana.RenderMarkdown` for the form preview and answer `{html}`; a refused output is a 422 `validation_failed` on `markdown`. Needs any backend session. |
| GET `/{vendor}/{plugin}/{controller}/schema/list`, `.../schema/form`, `.../schema/relation/{name}` | Localized list, form and relation schemas. | | GET `/{vendor}/{plugin}/{controller}/schema/list`, `.../schema/form`, `.../schema/relation/{name}` | Localized list, form and relation schemas. |
@@ -191,7 +191,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.CompileList` / `cabana.CompileForm` | Compile a controller's list and form YAML into cached schemas. | | `cabana.CompileList` / `cabana.CompileForm` | Compile a controller's list and form YAML into cached schemas. |
| `cabana.ListSchema` / `cabana.FormSchema` / `cabana.RelationSchema` | Locale-neutral compiled schemas; each request works on a localized copy. | | `cabana.ListSchema` / `cabana.FormSchema` / `cabana.RelationSchema` | Locale-neutral compiled schemas; each request works on a localized copy. |
| `cabana.CompiledController` | One controller after compilation: list, form, relations and writable fields. | | `cabana.CompiledController` | One controller after compilation: list, form, relations and writable fields. |
| `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. | | `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. `cabana.Registry.Setting` returns singleton settings only, never a controller link. |
| `cabana.CRUDService` | Schema-projected show, create, update, delete, bulk delete and relation options; `cabana.CRUDService.BulkAction` runs a declared bulk action on a scoped, locked id set. | | `cabana.CRUDService` | Schema-projected show, create, update, delete, bulk delete and relation options; `cabana.CRUDService.BulkAction` runs a declared bulk action on a scoped, locked id set. |
| `cabana.BulkAction` | One entry of a list schema's `bulkActions`: name, localized label and optional confirm text. | | `cabana.BulkAction` | One entry of a list schema's `bulkActions`: name, localized label and optional confirm text. |
| `cabana.BulkActionResult` | Answer of the bulk action route: the localized `message` and the `affected` count. | | `cabana.BulkActionResult` | Answer of the bulk action route: the localized `message` and the `affected` count. |

View File

@@ -131,13 +131,18 @@ func (r *Registry) Get(id string) (*CompiledController, bool) {
return cc, ok && cc != nil return cc, ok && cc != nil
} }
// Setting returns one compiled singleton setting by stable code. // Setting returns one compiled singleton setting by stable code. Link entries
// (pact.SettingsItem with Controller set) are not singletons and are not
// returned, so the singleton settings endpoints answer 404 for their codes.
func (r *Registry) Setting(code string) (*CompiledSetting, bool) { func (r *Registry) Setting(code string) (*CompiledSetting, bool) {
if r == nil { if r == nil {
return nil, false return nil, false
} }
setting, ok := r.settings[code] setting, ok := r.settings[code]
return setting, ok && setting != nil if !ok || setting == nil || setting.Item.Controller != "" {
return nil, false
}
return setting, true
} }
// Allows reports whether principal satisfies any of the required permission // Allows reports whether principal satisfies any of the required permission

View File

@@ -0,0 +1,24 @@
package cabana_test
import "git.golem15.com/golem15/summercms/modules/pact"
// LinkedBlogPlugin adds a settings entry that opens the posts controller from
// the Settings page.
type LinkedBlogPlugin struct{ BlogPlugin }
// Settings keeps the singleton page and adds a controller link.
func (p *LinkedBlogPlugin) Settings() []pact.SettingsItem {
// docs:start settings-link
link := pact.SettingsItem{
Code: "posts",
Label: "acme.blog::lang.posts.title",
Description: "acme.blog::lang.posts.description",
Category: "acme.blog::lang.plugin.name",
Icon: "icon-pencil",
Order: 500,
Permissions: []string{"acme.blog.access_settings"},
Controller: "acme.blog.posts",
}
// docs:end settings-link
return append(p.BlogPlugin.Settings(), link)
}

View File

@@ -101,3 +101,20 @@ func TestDocsDeclarations(t *testing.T) {
t.Fatal("BlogSettings has no posts_per_page rule") t.Fatal("BlogSettings has no posts_per_page rule")
} }
} }
// TestDocsSettingsLink activates the plugin with a settings entry that links
// to its posts controller, as the Settings page shows.
func TestDocsSettingsLink(t *testing.T) {
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
t.Fatal(err)
}
_ = cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes")
if _, err := cabana.Activate(backpack.New(cfg), []party.Plugin{&LinkedBlogPlugin{}}); err != nil {
t.Fatal(err)
}
settings := (&LinkedBlogPlugin{}).Settings()
if len(settings) != 2 || settings[1].Controller != "acme.blog.posts" || settings[1].Model != "" {
t.Fatalf("Settings() = %+v", settings)
}
}

View File

@@ -29,6 +29,9 @@ type SettingsEntry struct {
Order int `json:"order"` Order int `json:"order"`
Keywords []string `json:"keywords"` Keywords []string `json:"keywords"`
Model string `json:"model"` Model string `json:"model"`
// Controller is the admin controller a link entry opens; empty for a
// singleton settings form.
Controller string `json:"controller"`
} }
// Metadata returns only entries the backend principal may open. Denied // Metadata returns only entries the backend principal may open. Denied
@@ -65,7 +68,9 @@ func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, t
}) })
for _, compiled := range r.settings { for _, compiled := range r.settings {
item := compiled.Item item := compiled.Item
if !Allows(principal, item.Permissions) { // A link entry is listed only when the principal can also open its
// controller, so a denied controller ID never leaks.
if !Allows(principal, item.Permissions) || (item.Controller != "" && !r.canOpen(principal, item.Controller)) {
continue continue
} }
keywords := append([]string(nil), item.Keywords...) keywords := append([]string(nil), item.Keywords...)
@@ -77,6 +82,7 @@ func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, t
Description: translateKey(ctx, tr, item.Description), Description: translateKey(ctx, tr, item.Description),
Category: translateKey(ctx, tr, item.Category), Icon: item.Icon, Category: translateKey(ctx, tr, item.Category), Icon: item.Icon,
Order: item.Order, Keywords: keywords, Model: item.Model, Order: item.Order, Keywords: keywords, Model: item.Model,
Controller: item.Controller,
}) })
} }
sort.SliceStable(settings, func(i, j int) bool { sort.SliceStable(settings, func(i, j int) bool {

View File

@@ -196,6 +196,16 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error {
if _, exists := reg.settings[item.Code]; exists { if _, exists := reg.settings[item.Code]; exists {
return fmt.Errorf("cabana: duplicate setting %s", item.Code) return fmt.Errorf("cabana: duplicate setting %s", item.Code)
} }
if item.Controller != "" {
// A link entry opens an admin controller and needs no
// singleton form, so no AdminFS either.
compiled, err := compileSettingLink(plugin.ID(), item)
if err != nil {
return err
}
reg.settings[item.Code] = compiled
continue
}
if !hasAssets || assets == nil || assets.AdminFS() == nil { if !hasAssets || assets == nil || assets.AdminFS() == nil {
return fmt.Errorf("cabana: plugin %s has settings but no AdminFS", plugin.ID()) return fmt.Errorf("cabana: plugin %s has settings but no AdminFS", plugin.ID())
} }
@@ -250,6 +260,11 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error {
if err := reg.validatePermissions("setting "+code, setting.Item.Permissions); err != nil { if err := reg.validatePermissions("setting "+code, setting.Item.Permissions); err != nil {
return err return err
} }
if controller := setting.Item.Controller; controller != "" {
if _, ok := reg.byID[controller]; !ok {
return fmt.Errorf("cabana: setting %s references unknown controller %s", code, controller)
}
}
} }
return nil return nil
} }

View File

@@ -17,7 +17,8 @@ import (
) )
// CompiledSetting is a singleton settings registration after strict schema // CompiledSetting is a singleton settings registration after strict schema
// compilation and writable-field binding. // compilation and writable-field binding. For a link entry (Item.Controller
// set) Form and Writable are nil.
type CompiledSetting struct { type CompiledSetting struct {
PluginID string PluginID string
Item pact.SettingsItem Item pact.SettingsItem
@@ -34,6 +35,19 @@ type SettingsResult struct {
// SettingsService reads and transactionally updates compiled singleton rows. // SettingsService reads and transactionally updates compiled singleton rows.
type SettingsService struct{ DB *gorm.DB } type SettingsService struct{ DB *gorm.DB }
// 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) { func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*CompiledSetting, error) {
if item.NewModel == nil { if item.NewModel == nil {
return nil, fmt.Errorf("cabana: setting %s has no model factory", item.Code) return nil, fmt.Errorf("cabana: setting %s has no model factory", item.Code)

View File

@@ -0,0 +1,202 @@
package cabana
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// settingsLinkItem is a settings entry that opens an admin controller instead
// of a singleton form.
func settingsLinkItem() pact.SettingsItem {
return pact.SettingsItem{
Code: "locales", Label: "Locales", Description: "Manage locales", Category: "Demo",
Icon: "languages", Order: 30, Permissions: []string{"acme.demo.manage_settings"},
Controller: "acme.demo.widgets",
}
}
// settingsLinkPlugin declares only a link entry and ships no admin assets.
func settingsLinkPlugin(items ...pact.SettingsItem) contributionPlugin {
return contributionPlugin{
id: "acme.links",
permissions: []pact.Permission{{Code: "acme.links.unused", Roles: []string{"developer"}}},
settings: items,
}
}
// settingsLinkRegistry compiles the metadata plugin (one singleton setting)
// together with a plugin holding one controller link.
func settingsLinkRegistry(t *testing.T) *Registry {
t.Helper()
reg := metadataRegistry()
if err := compileContributions(reg, []party.Plugin{metadataPlugin(), settingsLinkPlugin(settingsLinkItem())}); err != nil {
t.Fatal(err)
}
return reg
}
func TestSettingsLinkCompilesWithoutAdminFS(t *testing.T) {
reg := settingsLinkRegistry(t)
stored, ok := reg.settings["locales"]
if !ok || stored == nil {
t.Fatal("link entry was not registered")
}
if stored.Form != nil || stored.Writable != nil || stored.Item.Controller != "acme.demo.widgets" || stored.PluginID != "acme.links" {
t.Fatalf("link entry = %#v", stored)
}
if _, ok := reg.Setting("locales"); ok {
t.Fatal("Registry.Setting returned a link entry")
}
if setting, ok := reg.Setting("demo"); !ok || setting.Form == nil {
t.Fatalf("singleton lookup = %#v %v", setting, ok)
}
}
func TestSettingsLinkCompileValidation(t *testing.T) {
withForm := settingsLinkItem()
withForm.Form = "models/settings/fields.yaml"
withFactory := settingsLinkItem()
withFactory.NewModel = func() any { return &singletonSetting{} }
withModel := settingsLinkItem()
withModel.Model = "Settings"
missing := settingsLinkItem()
missing.Controller = "acme.demo.missing"
duplicate := settingsLinkItem()
duplicate.Code = "demo"
unknownPermission := settingsLinkItem()
unknownPermission.Permissions = []string{"acme.demo.nope"}
cases := []struct {
name string
item pact.SettingsItem
want string
}{
{"form", withForm, "cabana: setting locales links controller acme.demo.widgets and declares a singleton form"},
{"model factory", withFactory, "cabana: setting locales links controller acme.demo.widgets and declares a singleton form"},
{"model", withModel, "cabana: setting locales links controller acme.demo.widgets and names a model"},
{"unknown controller", missing, "cabana: setting locales references unknown controller acme.demo.missing"},
{"duplicate code", duplicate, "duplicate setting demo"},
{"unknown permission", unknownPermission, "unknown permission"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := compileContributions(metadataRegistry(), []party.Plugin{metadataPlugin(), settingsLinkPlugin(tc.item)})
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want %q", err, tc.want)
}
})
}
}
func TestSettingsLinkMetadata(t *testing.T) {
reg := settingsLinkRegistry(t)
developer := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: reg.rolePermissions("developer")}
_, settings := reg.Metadata(context.Background(), developer, nil)
if len(settings) != 2 {
t.Fatalf("developer settings = %#v", settings)
}
byCode := map[string]SettingsEntry{}
for _, entry := range settings {
byCode[entry.Code] = entry
}
link, single := byCode["locales"], byCode["demo"]
if link.Controller != "acme.demo.widgets" || link.Model != "" || link.Label != "Locales" || link.Order != 30 {
t.Fatalf("link entry = %#v", link)
}
if link.Keywords == nil {
t.Fatal("link entry keywords are nil")
}
if single.Controller != "" || single.Model != "Settings" {
t.Fatalf("singleton entry = %#v", single)
}
// The principal passes the item's permission but cannot open the
// controller (it requires acme.demo.access): the link is hidden, the
// singleton stays.
restricted := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.demo.manage_settings": true}}
_, settings = reg.Metadata(context.Background(), restricted, nil)
if len(settings) != 1 || settings[0].Code != "demo" {
t.Fatalf("restricted settings = %#v", settings)
}
// Controller access alone does not pass the item's own permission.
controllerOnly := &bouncer.Principal{ID: 3, Backend: true, PermissionGrants: map[string]bool{"acme.demo.access": true}}
_, settings = reg.Metadata(context.Background(), controllerOnly, nil)
if len(settings) != 0 {
t.Fatalf("controller-only settings = %#v", settings)
}
}
func TestSettingsLinkEndpoints(t *testing.T) {
reg := settingsLinkRegistry(t)
svc := &service{reg: reg}
developer := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: reg.rolePermissions("developer")}
request := func(method, rel, code string) *http.Request {
var body *strings.Reader
if method == http.MethodPut {
body = strings.NewReader(`{"enabled":true}`)
} else {
body = strings.NewReader("")
}
req := httptest.NewRequest(method, adminAPI(rel), body)
if code != "" {
req.SetPathValue("code", code)
}
return req.WithContext(bouncer.WithUser(req.Context(), developer))
}
for _, tc := range []struct {
name string
method string
rel string
handler func(*service, http.ResponseWriter, *http.Request)
}{
{"schema", http.MethodGet, "/settings/locales/schema", (*service).settingsSchema},
{"get", http.MethodGet, "/settings/locales", (*service).settingsGet},
{"put", http.MethodPut, "/settings/locales", (*service).settingsPut},
} {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
tc.handler(svc, rec, request(tc.method, tc.rel, "locales"))
if rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), `"not_found"`) {
t.Fatalf("%s %s = %d %s", tc.method, tc.rel, rec.Code, rec.Body.String())
}
})
}
rec := httptest.NewRecorder()
svc.settingsList(rec, request(http.MethodGet, "/settings", ""))
if rec.Code != http.StatusOK {
t.Fatalf("GET /settings = %d %s", rec.Code, rec.Body.String())
}
var envelope struct {
Data []map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if len(envelope.Data) != 2 {
t.Fatalf("GET /settings data = %#v", envelope.Data)
}
for _, entry := range envelope.Data {
controller, ok := entry["controller"]
if !ok {
t.Fatalf("entry without controller key: %#v", entry)
}
want := ""
if entry["code"] == "locales" {
want = "acme.demo.widgets"
}
if controller != want {
t.Fatalf("entry %v controller = %v, want %q", entry["code"], controller, want)
}
}
}

View File

@@ -12,7 +12,7 @@ Capability interfaces that compiled plugins implement to contribute routes, conf
- Plugin capability interfaces: `pact.HasRoutes`, `pact.HasConfig`, `pact.HasMigrations`, `pact.HasCommands`, `pact.HasModels`, `pact.HasJobs`, `pact.HasSchedule`, `pact.HasLang`, `pact.HasLangOverrides` and `pact.HasMailTemplates`. - Plugin capability interfaces: `pact.HasRoutes`, `pact.HasConfig`, `pact.HasMigrations`, `pact.HasCommands`, `pact.HasModels`, `pact.HasJobs`, `pact.HasSchedule`, `pact.HasLang`, `pact.HasLangOverrides` and `pact.HasMailTemplates`.
- HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`. - HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`.
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. - Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. A settings entry is either a singleton form or a link that opens an admin controller from the Settings page.
- Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`. - Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`.
- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), and `pact.AdminPartialData` (the curated view model a partial template renders). - Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), and `pact.AdminPartialData` (the curated view model a partial template renders).
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), form-only fields that reach those hooks without being model columns (`pact.FormVirtualFields`), validation rules per operation for admin saves (`pact.FormRules`), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`). - Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), form-only fields that reach those hooks without being model columns (`pact.FormVirtualFields`), validation rules per operation for admin saves (`pact.FormRules`), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`).
@@ -100,7 +100,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.HasMailTemplates` | Ships mail templates and layout aliases. | | `pact.HasMailTemplates` | Ships mail templates and layout aliases. |
| `pact.Permission` | One backend permission entry (code, tab, label, roles). | | `pact.Permission` | One backend permission entry (code, tab, label, roles). |
| `pact.NavigationItem` | One backend navigation entry, with an optional side menu. | | `pact.NavigationItem` | One backend navigation entry, with an optional side menu. |
| `pact.SettingsItem` | One settings screen entry. | | `pact.SettingsItem` | One settings screen entry: a singleton settings form (`Model`, `Form`, `NewModel`), or a link to an admin controller when `Controller` names its ID, as a WinterCMS `'url' => Backend::url(...)` entry. |
| `pact.AdminController` | Admin controller identity: ID, model name and YAML config directory. | | `pact.AdminController` | Admin controller identity: ID, model name and YAML config directory. |
| `pact.AdminAssets` | Embedded tree of the plugin's admin YAML. | | `pact.AdminAssets` | Embedded tree of the plugin's admin YAML. |
| `pact.AdminRecordSource` | Supplies a new model record for the generic admin handlers. | | `pact.AdminRecordSource` | Supplies a new model record for the generic admin handlers. |

View File

@@ -398,7 +398,9 @@ type HasNavigation interface {
Navigation() []NavigationItem Navigation() []NavigationItem
} }
// SettingsItem is one registerSettings() entry. // SettingsItem is one registerSettings() entry. An item is either a singleton
// settings form (Model, Form and NewModel) or a link to an admin controller
// (Controller), never both.
type SettingsItem struct { type SettingsItem struct {
Code string Code string
Label string Label string
@@ -409,6 +411,11 @@ type SettingsItem struct {
Order int Order int
Keywords []string Keywords []string
Permissions []string Permissions []string
// Controller, when set, makes the entry a link to that admin controller
// ID (vendor.plugin.controller): the equivalent of a WinterCMS
// registerSettings 'url' => Backend::url(...) entry. A link entry
// declares no Model, Form or NewModel and is not a singleton.
Controller string
Form string `json:"-"` Form string `json:"-"`
NewModel func() any `json:"-"` NewModel func() any `json:"-"`
} }