feat(cabana): plugin updates and marketplace search on the Plugins screen
- GET /plugins looks up each versioned plugin's releases through kiosk.Client and applies plugin:update's rule (NewestCompatible above the BaseVersion), with the update command and the framework a newer release needs - GET /plugins/marketplace forwards q and category (validated like the registry), marks installed and incompatible plugins and returns the install command; ids outside the plugin ID shape are dropped - 4 s per-call timeout, at most 4 lookups at once, in-process cache (searches 5 min, releases 15 min, failures 1 min); a failing registry gives status unavailable, an empty plugins.registry_url sends nothing - SPA: update badges and Marketplace tab with copy buttons (clipboard fallback), hidden when the registry is disabled; no install button - OpenAPI, SPA types, inventories, conformance case, en/pl strings, docs
This commit is contained in:
@@ -57,7 +57,7 @@ Use cases:
|
||||
- Roles screen: Settings → System → Roles (controller `cabana.RolesControllerID`, permission `cabana.PermissionManageRoles`) lists, creates, edits and deletes backend roles with name, code (slugged from the name), description and a checkbox permission editor. System roles (`is_system`, or a code that code-declared `pact.Permission` grants attach to) keep their code and cannot be deleted, a role any administrator holds (deleted ones included) cannot be deleted, a code another role uses is refused, and a non-superuser editor cannot add or remove a permission they lack. The update form's Administrators tab lists the role's holders read-only (a form partial): superusers only for superuser editors, deleted and deactivated holders marked, the first 100 with a note naming the total, each login linked to the Administrators preview for an editor holding `cabana.PermissionManageUsers`; roles are assigned only on the Administrators form.
|
||||
- Record actions on another plugin's controller: a plugin implementing `pact.HasAdminActionExtensions` adds `pact.AdminRecordAction` entries to any admin controller by its ID (`pact.AdminActionExtension`), its own, another plugin's or the admin's own Administrators screen (`cabana.AdministratorsControllerID`). They are merged at boot after the target's own record actions, in plugin activation order, offered on the target's preview screen and run through the same row-locked record action route; the target's `RequiredPermissions` and the action's own `Permissions` both apply. Contributions targeting `cabana.AdministratorsControllerID` run `guardOutranked` before `Run` (403 `user.outranked`, `Run` not called). An unknown controller ID, a target without a preview block, a name the target already registers (the owner's or an earlier contribution), a reserved or non-identifier name, a missing `Run` or an empty label stops the boot. Extensions are compiled only when the admin is active.
|
||||
- Form fields on another plugin's controller: a `pact.AdminActionExtension` with `Form` set to a `pact.AdminFormExtension` places fields and tabs in any admin controller's form by its ID. Its fields document is the `fields.yaml` format plus `before:`/`after:` anchors, `zone:` (only the tab zone for now) and a `tabs:` list, compiled at boot with every `fields.yaml` check; extensions apply in plugin activation order and the form is sent in the compiled order, so the SPA adds no ordering. `Hide` removes owner fields, `Permissions` and `Visible` decide per request who sees the fields (schema, show and save alike), widget fields run the extension's `Actions` and partial fields render from its `Dir` through `PartialData`. Extension values leave the save body before any owner path reads them; `Save` runs in the record's save transaction after the owner row is written (`pact.AdminFormErrors` answers 422, its notices join `meta.notices`) and `Load` serves the values on show. A form no extension lays out compiles unchanged. Unknown anchors, duplicate names, anchor cycles, anchors outside `Requires()`, other zones, `fileupload` fields and clashing widget action or partial names stop the boot naming the plugin, the controller and the name.
|
||||
- Plugins screen: Settings → System → Plugins opens the admin SPA route `/system/plugins` (the settings entry carries `path` instead of a controller) for administrators holding `cabana.PermissionViewPlugins` (`backend.view_plugins`, granted to no role in code; superusers always pass). GET `/plugins` lists the plugins the application was built with from the `party.Installed` metadata `summer build` bakes (name, description, version, source, module and package), or only the IDs of `party.Activated` for an application built before that metadata existed, with the framework version and the state of the plugin registry (`plugins.registry_url`, read through `kiosk.RegistryURL`).
|
||||
- Plugins screen: Settings → System → Plugins opens the admin SPA route `/system/plugins` (the settings entry carries `path` instead of a controller) for administrators holding `cabana.PermissionViewPlugins` (`backend.view_plugins`, granted to no role in code; superusers always pass). GET `/plugins` lists the plugins the application was built with from the `party.Installed` metadata `summer build` bakes (name, description, version, source, module and package), or only the IDs of `party.Activated` for an application built before that metadata existed, with the framework version and the state of the plugin registry (`plugins.registry_url`, read through `kiosk.RegistryURL`). With the registry configured, cabana (never the SPA) looks up each versioned plugin's releases through `kiosk.Client` and marks an update when `kiosk.NewestCompatible` finds a release newer than the installed version's `kiosk.BaseVersion`, with the `summer plugin:update <id>` command; GET `/plugins/marketplace` searches the registry and adds installed and compatibility markers and the `summer plugin:install <id>` command. Registry calls have a 4 second timeout, at most four run at once, and answers are cached in process (searches 5 minutes, releases 15 minutes, failures 1 minute); a failing registry gives `status` unavailable and never a non-200 answer.
|
||||
- Administrators screen: when some plugin registers admin controllers, `cabana.Activate` also compiles the admin's own screens, an internal plugin with ID `summercms.cabana`. Settings → System → Administrators (controller `cabana.AdministratorsControllerID`, permission `cabana.PermissionManageUsers`) lists backend administrators with their role and last sign-in, opens a preview of each, where record actions (the admin's own and contributed ones) are offered, and creates and edits administrators: login, email, names, a password (hashed at `admin.password.bcrypt_cost`, at least 4 characters and at most 72 bytes), the role and per-administrator permission overrides. The server enforces the privilege rules in the save transaction: only a superuser sees superusers or sends the superuser switch, an administrator can neither grant permissions or roles above their own grants nor change an administrator who holds more than they do, their own role, superuser flag, permissions and password are not changed here, and the last active superuser cannot be demoted. A password set for another administrator ends their sessions (personal API tokens stay valid, as with `admin:reset-password`). Administrators are activated, deactivated and restored by record and bulk actions and deleted softly through `deleted_at` (never hard-deleted); the list shows deleted and deactivated rows with their row states and filters deleted ones, and the preview shows a status hint. Deactivating or deleting an administrator ends their sessions and stops their personal API tokens; the last active superuser cannot be demoted, deactivated or deleted on any of these paths (a transaction-scoped advisory lock serializes the check). The permission codes `cabana.PermissionManageUsers` and `cabana.PermissionManageRoles` are registered with no role grant, so after an upgrade only superusers see the screens until an administrator grants them. An application without admin controllers still activates no admin.
|
||||
- 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.
|
||||
- Prompted record actions: a `pact.AdminRecordAction` with a `Prompt` (`pact.AdminActionPrompt`) asks the administrator for one text value in its confirm dialog. The record response serves it as `prompt` on the action's `cabana.RecordAction` (absent for every other action, so existing payloads are unchanged), and the action route accepts the body key `payload`, a JSON string, only for such an action: trimmed, not empty and at most `max_length` runes (422 on `payload` with `backend::lang.form.action_prompt_required` or `action_prompt_too_long`, `backend::lang.form.action_prompt_invalid` for another JSON type), then handed to `Run` as `pact.AdminRecordActionInput.Value`. Every other record action keeps refusing `record_id`, `values` and `payload`. A prompt without a label fails boot, owned or contributed.
|
||||
@@ -93,7 +93,8 @@ Guarded routes run surf's `locale.from-principal` after the backend guard: an in
|
||||
| GET `/account/contributions` | The sections plugins contribute to My account (`cabana.AccountContributionView`): translated label, description and status for the signed-in administrator, and the actions (`cabana.AccountContributionActionView`) they hold the permissions for and whose `Applies` reports true. A section whose status or one of whose actions' `Applies` failed carries `error: true`. An empty list when no plugin contributes. Interactive session only. |
|
||||
| POST `/account/contributions/{name}/actions/{action}` | Run one contributed action for the signed-in administrator: 404 for an unknown section or action, 403 without the action's permissions, 409 when it no longer applies; `Run` gets the administrator's id inside a transaction and answers `cabana.AccountActionResultView` (translated `message`, optional `url`, only an `http`/`https` or same-origin URL). Interactive session only, AJAX header required. |
|
||||
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open and, for a navigation entry with a `Visible` func, that it answers true for the request; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form, and `path` (only on the admin's own Plugins entry) names the SPA route it opens. |
|
||||
| GET `/plugins` | The Plugins screen (`cabana.PluginsOverview`): the framework version the application was built with, the registry state (`cabana.RegistryState`: `enabled`, `status` ok or disabled) and every activated plugin in summer.yaml order (`cabana.InstalledPlugin`). 403 without `cabana.PermissionViewPlugins`. |
|
||||
| GET `/plugins` | The Plugins screen (`cabana.PluginsOverview`): the framework version the application was built with, the registry state (`cabana.RegistryState`: `enabled`, `status` ok, disabled or unavailable) and every activated plugin in summer.yaml order (`cabana.InstalledPlugin`) with, for a plugin the registry lists, `latest`, `updateAvailable`, `updateCommand` and `latestFramework`. 403 without `cabana.PermissionViewPlugins`. |
|
||||
| GET `/plugins/marketplace` | The registry's search for the optional `q` (at most 100 characters) and `category` (`^[a-z0-9-]{1,40}$`; anything else is a 400 `invalid_query`), as `cabana.MarketplaceResponse`: `cabana.MarketplacePlugin` entries with `installed`, `compatible` and `installCommand`, and `meta` with the registry state, `total` and the registry's `categories`. An empty list when the registry is disabled or unavailable. 403 without `cabana.PermissionViewPlugins`. |
|
||||
| GET `/field-types` | The plugin field types (`pact.HasFormFieldTypes`) sorted by name, each with its `element`, `translatable` flag and the versioned URLs of its `scripts` and `styles` (`cabana.FieldTypesResponse`); an empty list when no plugin registers one. The SPA loads them after sign-in. Needs any backend session. |
|
||||
| POST `/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` | Store one multipart part named `file` through the `Upload` of the field's plugin field type and answer 201 `{url}` (`cabana.FieldUploadResponse`). `{id}` 0 is the create form and passes no record; any other id loads the record through the form scope (404 outside it). 403 when the controller does not declare the save the upload belongs to (create for `{id}` 0, which needs the list's `create` toolbar button, update otherwise), as the save routes answer. 404 for a field without a plugin type with `Upload` or whose context does not allow the operation, 413 over `http.body_limits.upload_bytes`, 422 on the field for a malformed body or a `pact.FormFieldError`. The body is read and checked whole before `Upload` runs, so a refused request stores nothing. |
|
||||
| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. |
|
||||
@@ -301,7 +302,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `cabana.PermissionViewPlugins` | The permission code of the Plugins screen (`backend.view_plugins`), granted to no role in code. |
|
||||
| `cabana.PluginsOverview` / `cabana.InstalledPlugin` / `cabana.RegistryState` | The GET `/plugins` payload: framework version, registry state and the installed plugins with their baked metadata. |
|
||||
| `cabana.SettingsEntry.Path` | The SPA route a settings entry of the admin's own opens (`/system/plugins`); empty, and omitted from JSON, for plugin entries. |
|
||||
| `cabana.AdminPluginsInstalled` | Swag annotation of GET `/plugins`. |
|
||||
| `cabana.MarketplaceResponse` / `cabana.MarketplacePlugin` / `cabana.MarketplaceMeta` | The GET `/plugins/marketplace` answer: the registry's search with installed and compatibility markers and install commands, plus the registry state, total and categories. |
|
||||
| `cabana.AdminPluginsInstalled` / `cabana.AdminPluginsMarketplace` | Swag annotations of GET `/plugins` and GET `/plugins/marketplace`. |
|
||||
| `cabana.AdministratorsControllerID` / `cabana.RolesControllerID` | The admin controller IDs of the Administrators (`summercms.cabana.users`) and Roles (`summercms.cabana.roles`) screens; a plugin names the first in `pact.AdminActionExtension.Controller` to add record actions to it. |
|
||||
| `cabana.BackendUser.Fillable` / `cabana.BackendUser.Rules` | The columns the Administrators screen fills from a form (login, email, first and last name) and their base rules. |
|
||||
| `cabana.BackendUserRole.Fillable` / `cabana.BackendUserRole.Rules` | The columns a role form fills (name, code, description) and their base rules. |
|
||||
@@ -404,7 +406,7 @@ TOKEN=$(./bin/acme admin:token:create admin@example.com --name agent --scope blo
|
||||
|
||||
- SummerCMS modules: [backpack](../backpack/README.md), [boardwalk](../boardwalk/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [diary](../diary/README.md) (component logger `cabana`; the `admin.auth` audit record keeps its message), [kiosk](../kiosk/README.md) (the plugin registry URL and client of the Plugins screen), [lagoon](../lagoon/README.md), [pact](../pact/README.md), [party](../party/README.md), [phrasebook](../phrasebook/README.md), [seaglass](../seaglass/README.md) (sanitizes `richeditor` values), [towel](../towel/README.md).
|
||||
- Third-party: `gorm.io/gorm` (with `gorm.io/gorm/clause`), `github.com/goccy/go-yaml` (with its `ast` package), `golang.org/x/net/html` (with its `atom` package; parses rendered partials for the allowlist walk).
|
||||
- Standard library: `bytes`, `context`, `crypto/sha256`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `html/template`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `path`, `reflect`, `regexp`, `slices`, `sort`, `strconv`, `strings`, `time`, `unicode/utf8`.
|
||||
- Standard library: `bytes`, `context`, `crypto/sha256`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `html/template`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `net/url`, `path`, `reflect`, `regexp`, `slices`, `sort`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`.
|
||||
- Tests additionally use `github.com/testcontainers/testcontainers-go` and its `modules/postgres` package, and [signpost](../signpost/README.md) for the dependent dropdown fixture.
|
||||
|
||||
## Testing
|
||||
@@ -429,4 +431,4 @@ The localization of refusals is covered by Docker-free unit tests in `localize_e
|
||||
|
||||
The Roles form's holders list is covered by the Docker-free TestRolesHoldersPartial in `backend_screens_test.go` (the users partial field on the update form's Administrators tab, the view model guard, links, state lines, escaped holder strings, the empty line and the note) and the Postgres test TestRolesHolders in `roles_postgres_test.go` (superusers hidden from non-superuser editors, D-03; deleted and deactivated holders; preview links only with `cabana.PermissionManageUsers`; the empty role; the 100-row cap with its note; 403 without `cabana.PermissionManageRoles`).
|
||||
|
||||
The Plugins screen is covered by the Docker-free TestPluginsScreenSmoke in `plugins_smoke_test.go`: the installed list from `party.Installed` and the `party.Activated` fallback, 401 and 403 without `cabana.PermissionViewPlugins`, and the settings entry with its `path` only for allowed administrators.
|
||||
The Plugins screen is covered by the Docker-free TestPluginsScreenSmoke in `plugins_smoke_test.go`: the installed list from `party.Installed` and the `party.Activated` fallback, 401 and 403 without `cabana.PermissionViewPlugins`, the settings entry with its `path` only for allowed administrators, and against a fake registry the update rule (a git describe version compares by its base tag), unlisted and local plugins, a failing or slow registry, a disabled one (no request), the marketplace search with its validation and the cache windows.
|
||||
|
||||
Reference in New Issue
Block a user