From 8b1cb244de2a8815ea87212af4a23e37b6502db6 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 28 Sep 2026 23:41:17 +0200 Subject: [PATCH] feat(10.1-01): serve controller JS/CSS and run registered toolbar actions - boardwalk exports ContentType and SetSecurityHeaders - pact.AdminClientAssets files are read and hashed at boot and served by exact key under {prefix}/assets/{vendor}/{plugin}/ with nosniff, CSP, CORP, no-cache and an ETag; a miss falls through to the SPA - list and form schemas carry assets URLs with a ?v= hash - toolbar.buttons resolves create, delete and registered actions after decode; toolbarActions is permission-filtered per admin - POST .../toolbar/{action} behind requireAjax and action permissions --- admin/openapi/admin.json | 177 ++++++++++++++++++ admin/src/api/schema.d.ts | 109 +++++++++++ admin/tests/fixtures/settings.json | 4 + admin/tests/fixtures/widgets.form-schema.json | 4 + admin/tests/fixtures/widgets.list-schema.json | 2 + modules/boardwalk/README.md | 2 + modules/boardwalk/boardwalk.go | 14 +- modules/boardwalk/boardwalk_test.go | 8 +- modules/cabana/README.md | 11 +- modules/cabana/actions.go | 44 +++++ modules/cabana/admin_openapi.go | 21 +++ modules/cabana/contracts.go | 8 + modules/cabana/extension.go | 99 ++++++++++ modules/cabana/http.go | 19 ++ modules/cabana/list_schema.go | 66 +++++-- modules/cabana/list_schema_test.go | 10 +- modules/cabana/openapi_conformance_test.go | 77 +++++++- modules/cabana/phase10_coverage_test.go | 3 +- modules/cabana/phase10_csrf_test.go | 6 +- modules/cabana/plugin_assets.go | 54 ++++++ modules/cabana/registry.go | 21 ++- modules/cabana/schema_types.go | 74 ++++++-- modules/cabana/security_coverage_test.go | 3 + 23 files changed, 772 insertions(+), 64 deletions(-) create mode 100644 modules/cabana/plugin_assets.go diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index 670cf16..6801d61 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -170,6 +170,27 @@ ], "type": "object" }, + "cabana.ControllerAssets": { + "properties": { + "scripts": { + "items": { + "type": "string" + }, + "type": "array" + }, + "styles": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "scripts", + "styles" + ], + "type": "object" + }, "cabana.Envelope-array_cabana_FilterOption": { "properties": { "data": { @@ -609,6 +630,14 @@ }, "cabana.FormView": { "properties": { + "assets": { + "allOf": [ + { + "$ref": "#/components/schemas/cabana.ControllerAssets" + } + ], + "description": "Assets are the controller's plugin JS and CSS URLs; a settings form\ncarries empty lists." + }, "fields": { "items": { "$ref": "#/components/schemas/cabana.FormField" @@ -642,6 +671,7 @@ } }, "required": [ + "assets", "fields", "messages", "meta", @@ -838,6 +868,14 @@ }, "cabana.ListSchema": { "properties": { + "assets": { + "allOf": [ + { + "$ref": "#/components/schemas/cabana.ControllerAssets" + } + ], + "description": "Assets are the controller's plugin JS and CSS URLs (pact.AdminClientAssets)." + }, "bulkActions": { "items": { "$ref": "#/components/schemas/cabana.BulkAction" @@ -915,6 +953,13 @@ "title": { "type": "string" }, + "toolbarActions": { + "description": "ToolbarActions are the controller-registered toolbar.buttons entries\nthe requesting admin may run, in declared order, with their labels.", + "items": { + "$ref": "#/components/schemas/cabana.ToolbarAction" + }, + "type": "array" + }, "toolbarButtons": { "items": { "type": "string" @@ -923,6 +968,7 @@ } }, "required": [ + "assets", "bulkActions", "columns", "filters", @@ -935,6 +981,7 @@ "showSearch", "showSetup", "showSorting", + "toolbarActions", "toolbarButtons" ], "type": "object" @@ -1277,6 +1324,21 @@ }, "type": "object" }, + "cabana.ToolbarAction": { + "properties": { + "label": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "label", + "name" + ], + "type": "object" + }, "cabana.fieldContext": { "oneOf": [ { @@ -2730,6 +2792,121 @@ ] } }, + "/{vendor}/{plugin}/{controller}/toolbar/{action}": { + "post": { + "description": "Runs a controller-registered action that the list's toolbar.buttons declares. The body must be {}: a toolbar action takes no record ids or values, and its fill is always empty.", + "parameters": [ + { + "description": "Vendor", + "in": "path", + "name": "vendor", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Plugin", + "in": "path", + "name": "plugin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Controller", + "in": "path", + "name": "controller", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Action name", + "in": "path", + "name": "action", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.AdminActionRequest" + } + } + }, + "description": "Empty object", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.Envelope-cabana_AdminActionResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unprocessable Entity" + } + }, + "security": [ + { + "BackendBearer": [] + } + ], + "summary": "Run a toolbar action", + "tags": [ + "admin" + ] + } + }, "/{vendor}/{plugin}/{controller}/widgets/{field}": { "post": { "description": "Runs the controller action a `type: widget` field declares. The record is loaded through the controller's form scope (404 when out of scope); only the field's fill keys with scalar values reach the action and the response.", diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index f4309f9..e050cc6 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -1237,6 +1237,95 @@ export interface paths { patch?: never; trace?: never; }; + "/{vendor}/{plugin}/{controller}/toolbar/{action}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Run a toolbar action + * @description Runs a controller-registered action that the list's toolbar.buttons declares. The body must be {}: a toolbar action takes no record ids or values, and its fill is always empty. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Vendor */ + vendor: string; + /** @description Plugin */ + plugin: string; + /** @description Controller */ + controller: string; + /** @description Action name */ + action: string; + }; + cookie?: never; + }; + /** @description Empty object */ + requestBody: { + content: { + "application/json": components["schemas"]["cabana.AdminActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.Envelope-cabana_AdminActionResult"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/{vendor}/{plugin}/{controller}/widgets/{field}": { parameters: { query?: never; @@ -1953,6 +2042,10 @@ export interface components { "cabana.BulkResult": { deleted: number; }; + "cabana.ControllerAssets": { + scripts: string[]; + styles: string[]; + }; "cabana.Envelope-array_cabana_FilterOption": { data: components["schemas"]["cabana.FilterOption"][]; meta: components["schemas"]["cabana.SuccessMeta"]; @@ -2076,6 +2169,11 @@ export interface components { update: components["schemas"]["cabana.FormRedirect"]; }; "cabana.FormView": { + /** + * @description Assets are the controller's plugin JS and CSS URLs; a settings form + * carries empty lists. + */ + assets: components["schemas"]["cabana.ControllerAssets"]; fields: components["schemas"]["cabana.FormField"][]; /** @description Messages is the form's copy resolved in the request locale (D-13). */ messages: components["schemas"]["cabana.FormMessages"]; @@ -2141,6 +2239,8 @@ export interface components { total: number; }; "cabana.ListSchema": { + /** @description Assets are the controller's plugin JS and CSS URLs (pact.AdminClientAssets). */ + assets: components["schemas"]["cabana.ControllerAssets"]; bulkActions: components["schemas"]["cabana.BulkAction"][]; columns: components["schemas"]["cabana.ListColumn"][]; defaultSort?: components["schemas"]["cabana.ListSort"]; @@ -2164,6 +2264,11 @@ export interface components { showSetup: boolean; showSorting: boolean; title?: string; + /** + * @description ToolbarActions are the controller-registered toolbar.buttons entries + * the requesting admin may run, in declared order, with their labels. + */ + toolbarActions: components["schemas"]["cabana.ToolbarAction"][]; toolbarButtons: string[]; }; "cabana.ListSort": { @@ -2261,6 +2366,10 @@ export interface components { per_page?: number; total?: number; }; + "cabana.ToolbarAction": { + label: string; + name: string; + }; "cabana.fieldContext": string | string[]; "cabana.jsonScalar": (string | number | boolean) | null; }; diff --git a/admin/tests/fixtures/settings.json b/admin/tests/fixtures/settings.json index d22d9d8..61c1031 100644 --- a/admin/tests/fixtures/settings.json +++ b/admin/tests/fixtures/settings.json @@ -75,6 +75,10 @@ "meta": { "locale": "en" }, + "assets": { + "scripts": [], + "styles": [] + }, "redirects": { "create": { "redirect": "", diff --git a/admin/tests/fixtures/widgets.form-schema.json b/admin/tests/fixtures/widgets.form-schema.json index db14c2d..c64eba3 100644 --- a/admin/tests/fixtures/widgets.form-schema.json +++ b/admin/tests/fixtures/widgets.form-schema.json @@ -143,6 +143,10 @@ "meta": { "locale": "en" }, + "assets": { + "scripts": [], + "styles": [] + }, "redirects": { "create": { "redirect": "acme/demo/widgets/update/:id", diff --git a/admin/tests/fixtures/widgets.list-schema.json b/admin/tests/fixtures/widgets.list-schema.json index b222056..0839a18 100644 --- a/admin/tests/fixtures/widgets.list-schema.json +++ b/admin/tests/fixtures/widgets.list-schema.json @@ -10,6 +10,8 @@ "searchTerm": "", "recordUrl": "acme/demo/widgets/update/:id", "toolbarButtons": ["create", "delete"], + "toolbarActions": [], + "assets": { "scripts": [], "styles": [] }, "columns": [ { "key": "name", "label": "Name", "searchable": true, "sortable": true }, { "key": "code", "label": "Code", "searchable": true, "sortable": true }, diff --git a/modules/boardwalk/README.md b/modules/boardwalk/README.md index f1efca7..ad50c15 100644 --- a/modules/boardwalk/README.md +++ b/modules/boardwalk/README.md @@ -42,6 +42,8 @@ mux.Handle("/backend/", spa) | `boardwalk.Dist` | Returns the embedded build as an `fs.FS` rooted at `dist/`. | | `boardwalk.RewriteIndex` | Rewrites raw `index.html` bytes for a prefix; errors when the placeholder token is missing. | | `boardwalk.BaseToken` | The placeholder in `dist/index.html` that is replaced by the prefix. | +| `boardwalk.ContentType` | The Content-Type served for a file name, with explicit UTF-8 JavaScript and CSS types. Shared with the plugin asset route in cabana. | +| `boardwalk.SetSecurityHeaders` | Sets the admin security headers (nosniff, referrer policy, no framing, the `script-src 'self'` CSP, noindex) on a response. | ## Dependencies diff --git a/modules/boardwalk/boardwalk.go b/modules/boardwalk/boardwalk.go index aacf193..d5a6fe8 100644 --- a/modules/boardwalk/boardwalk.go +++ b/modules/boardwalk/boardwalk.go @@ -98,7 +98,7 @@ type handler struct { } func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - setSecurityHeaders(w.Header()) + SetSecurityHeaders(w.Header()) rel := strings.TrimPrefix(r.URL.Path, h.prefix) rel = strings.TrimPrefix(rel, "/") if rel == "api" || strings.HasPrefix(rel, "api/") { @@ -138,7 +138,7 @@ func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) http.NotFound(w, r) return } - w.Header().Set("Content-Type", contentType(name)) + w.Header().Set("Content-Type", ContentType(name)) if strings.HasPrefix(name, "assets/") { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { @@ -147,7 +147,10 @@ func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) http.ServeContent(w, r, path.Base(name), time.Time{}, bytes.NewReader(body)) } -func contentType(name string) string { +// ContentType is the Content-Type the admin serves for a file name: explicit +// UTF-8 JavaScript and CSS types (module scripts and nosniff'd stylesheets +// need them), then the platform MIME table, then application/octet-stream. +func ContentType(name string) string { ext := strings.ToLower(path.Ext(name)) if ct, ok := contentTypes[ext]; ok { return ct @@ -158,7 +161,10 @@ func contentType(name string) string { return "application/octet-stream" } -func setSecurityHeaders(h http.Header) { +// SetSecurityHeaders sets the admin response headers: nosniff, a same-origin +// referrer policy, no framing, the admin Content-Security-Policy +// (script-src 'self') and noindex. +func SetSecurityHeaders(h http.Header) { h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "same-origin") h.Set("X-Frame-Options", "DENY") diff --git a/modules/boardwalk/boardwalk_test.go b/modules/boardwalk/boardwalk_test.go index ff51bc3..4443138 100644 --- a/modules/boardwalk/boardwalk_test.go +++ b/modules/boardwalk/boardwalk_test.go @@ -273,13 +273,13 @@ func TestContentTypesAndCaching(t *testing.T) { if index.Header().Get("Cache-Control") != "no-store" || !strings.HasPrefix(index.Header().Get("Content-Type"), "text/html") { t.Fatalf("index headers = %v", index.Header()) } - if got := contentType("x.svg"); got != "image/svg+xml" { + if got := ContentType("x.svg"); got != "image/svg+xml" { t.Fatalf("svg type %q", got) } - if got := contentType("x.json"); got != "application/json" { + if got := ContentType("x.json"); got != "application/json" { t.Fatalf("json type %q", got) } - if got := contentType("x.unknown-ext"); got != "application/octet-stream" { + if got := ContentType("x.unknown-ext"); got != "application/octet-stream" { t.Fatalf("unknown type %q", got) } } @@ -432,7 +432,7 @@ func TestPhase10BoardwalkServing(t *testing.T) { if cc := get(mapped, testPrefix+"/robots.txt").Header().Get("Cache-Control"); cc != "no-cache" { t.Fatalf("non-asset file Cache-Control=%q, want no-cache", cc) } - if got := contentType("UPPER.JS"); got != "text/javascript; charset=utf-8" { + if got := ContentType("UPPER.JS"); got != "text/javascript; charset=utf-8" { t.Fatalf("extension case: %q", got) } }) diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 25b87fe..a429df9 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -15,6 +15,8 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte - Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md), and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write. - Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates and for linking and unlinking. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. - Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys with scalar values. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot. +- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file. +- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot. - Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. - Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. `cabana.Allows` implements the permission check: superusers pass, and grants ending in `.*` match by prefix. - 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`, 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. @@ -38,12 +40,17 @@ All paths are relative to `/api/v1`. A controller ID `vendor.plugin.cont | GET, PUT and DELETE `/{vendor}/{plugin}/{controller}/{id}` | Show, update and delete a record. | | POST `/{vendor}/{plugin}/{controller}/bulk-delete` | Delete a set of records in one transaction. | | POST `/{vendor}/{plugin}/{controller}/widgets/{field}` | Run the action of a `type: widget` field with an optional `record_id` and the fill snapshot; answers `{message, fill}`. | +| POST `/{vendor}/{plugin}/{controller}/toolbar/{action}` | Run a registered toolbar action with an empty `{}` body; answers `{message, fill: {}}`. | | GET `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. | | GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. | | POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. | Every path under the prefix that no API route matches is served by the admin SPA; unmatched API paths return the `not_found` error envelope instead. +### Controller assets + +`GET /assets/{vendor}/{plugin}/{file...}` serves the files controllers declare through `pact.AdminClientAssets`. A plugin file `assets/js/lookup.js` of plugin `acme.blog` is served at `/assets/acme/blog/js/lookup.js`, and the schemas list it as `/assets/acme/blog/js/lookup.js?v=`. The route is public, like the SPA shell, and serves only the exact files declared at boot, never the plugin's embedded tree: YAML and templates are not reachable, and any other path falls through to the SPA, which also serves its own build assets under `/assets/`. Each response carries an explicit JavaScript or CSS `Content-Type`, `X-Content-Type-Options: nosniff`, the admin Content-Security-Policy (`script-src 'self'`), `Cross-Origin-Resource-Policy: same-origin`, `Cache-Control: no-cache` and a sha256 `ETag`, so conditional requests answer 304 and a rebuilt binary is picked up at once. + ## Usage A plugin exposes an admin controller and embeds its YAML. `summer make:admin-controller` scaffolds the controller type and its four YAML files: @@ -114,6 +121,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. | | `cabana.AdminActionRequest` | Body of an action route: optional `record_id` and the widget's `values`. Unknown keys are refused. | | `cabana.AdminActionResult` | Answer of an action route: the localized `message` and the filtered `fill` object. | +| `cabana.ControllerAssets` | The `assets` object of list and form schemas: `scripts` and `styles` URL lists, always arrays. | +| `cabana.ToolbarAction` | One registered toolbar button in a list schema's `toolbarActions`: action name and localized label. | ## Configuration @@ -150,7 +159,7 @@ Both commands are added to every application binary by the generated `main` and - SummerCMS modules: [backpack](../backpack/README.md), [boardwalk](../boardwalk/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [lagoon](../lagoon/README.md), [pact](../pact/README.md), [party](../party/README.md), [phrasebook](../phrasebook/README.md), [towel](../towel/README.md). - Third-party: `gorm.io/gorm` (with `gorm.io/gorm/clause`), `github.com/goccy/go-yaml` (with its `ast` package). -- Standard library: `bytes`, `context`, `database/sql`, `encoding/json`, `errors`, `fmt`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `path`, `reflect`, `regexp`, `sort`, `strconv`, `strings`, `time`. +- Standard library: `bytes`, `context`, `crypto/sha256`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `path`, `reflect`, `regexp`, `sort`, `strconv`, `strings`, `time`. - Tests additionally use `github.com/testcontainers/testcontainers-go` and its `modules/postgres` package. ## Testing diff --git a/modules/cabana/actions.go b/modules/cabana/actions.go index b2d6b6e..7d705d2 100644 --- a/modules/cabana/actions.go +++ b/modules/cabana/actions.go @@ -61,6 +61,50 @@ func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) { }) } +// toolbarAction serves POST .../{controller}/toolbar/{action} (D-12): a +// registered action the list's toolbar.buttons declares. A toolbar action +// carries no record id and no values, so it can never become an unscoped +// record lookup; its answer's fill is always empty. +func (s *service) toolbarAction(w http.ResponseWriter, r *http.Request) { + s.protect(w, r, func(cc *CompiledController) { + action, ok := toolbarActionOf(cc, r.PathValue("action")) + if !ok { + WriteError(w, http.StatusNotFound, "not_found", msgNotFound) + return + } + if !s.allowAction(w, r, action) { + return + } + in, err := decodeActionRequest(r) + if err != nil { + writeCRUDError(w, err) + return + } + if in.RecordID != nil || in.Values != nil { + writeCRUDError(w, &ValidationError{Details: map[string]any{"body": []string{"A toolbar action takes no record_id or values."}}}) + return + } + s.runAction(w, r, cc, action, pact.AdminActionInput{}, nil) + }) +} + +// toolbarActionOf returns the registered action a list toolbar declares under +// name; the built-in create and delete are not actions. +func toolbarActionOf(cc *CompiledController, name string) (pact.AdminAction, bool) { + if cc == nil || cc.List == nil || builtinToolbarActions[name] { + return pact.AdminAction{}, false + } + declared := false + for _, button := range cc.List.ToolbarButtons { + declared = declared || button == name + } + if !declared { + return pact.AdminAction{}, false + } + action, ok := cc.Actions[name] + return action, ok +} + // allowAction applies the action's own permissions on top of the controller's // (already checked by protect). A denial is logged and answered 403. func (s *service) allowAction(w http.ResponseWriter, r *http.Request, action pact.AdminAction) bool { diff --git a/modules/cabana/admin_openapi.go b/modules/cabana/admin_openapi.go index 28d093f..dd00d1f 100644 --- a/modules/cabana/admin_openapi.go +++ b/modules/cabana/admin_openapi.go @@ -425,6 +425,27 @@ type AdminActionResult struct { // @Router /{vendor}/{plugin}/{controller}/widgets/{field} [post] func AdminWidgetAction() {} +// AdminToolbarAction documents the toolbar action route. +// +// @Summary Run a toolbar action +// @Description Runs a controller-registered action that the list's toolbar.buttons declares. The body must be {}: a toolbar action takes no record ids or values, and its fill is always empty. +// @Tags admin +// @Accept json +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Param action path string true "Action name" +// @Param body body AdminActionRequest true "Empty object" +// @Success 200 {object} Envelope[AdminActionResult] +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /{vendor}/{plugin}/{controller}/toolbar/{action} [post] +func AdminToolbarAction() {} + // AdminShow documents the record show route. // // @Summary Show an admin record diff --git a/modules/cabana/contracts.go b/modules/cabana/contracts.go index a52c4da..dfd8125 100644 --- a/modules/cabana/contracts.go +++ b/modules/cabana/contracts.go @@ -77,6 +77,11 @@ type CompiledController struct { // the single namespace that toolbar.buttons names and widget action: keys // resolve through. create and delete are reserved built-in names. Actions map[string]pact.AdminAction + + // scripts and styles are the controller's declared plugin JS and CSS, + // in declared order. + scripts []*pluginAsset + styles []*pluginAsset } // Registry is the immutable controller map keyed by controller ID. @@ -86,6 +91,9 @@ type Registry struct { roleGrants map[string]map[string]bool navigation []pact.NavigationItem settings map[string]*CompiledSetting + // assets are every controller's declared plugin files keyed by URL tail + // (vendor/plugin/); the asset route serves only these. + assets map[string]*pluginAsset } // Get returns the compiled controller for a D-09 id (vendor.plugin.controller). diff --git a/modules/cabana/extension.go b/modules/cabana/extension.go index d9214db..7f32266 100644 --- a/modules/cabana/extension.go +++ b/modules/cabana/extension.go @@ -1,11 +1,15 @@ package cabana import ( + "crypto/sha256" + "encoding/hex" "fmt" "io/fs" + "path" "regexp" "strings" + "git.golem15.com/golem15/summercms/modules/boardwalk" "git.golem15.com/golem15/summercms/modules/pact" ) @@ -20,6 +24,24 @@ var reservedWidgetTags = map[string]bool{ "font-face-uri": true, "font-face-format": true, "font-face-name": true, "missing-glyph": true, } +// assetSegment is one segment of the plugin ID in an asset URL. +var assetSegment = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// pluginAsset is one declared controller JS or CSS file, read from the +// plugin's embedded tree and hashed at boot. The asset route serves only +// these exact keys (D-13, D-15, D-16). +type pluginAsset struct { + // key is vendor/plugin/, the URL tail under + // {prefix}/assets/. + key string + body []byte + contentType string + // etag is the quoted hex sha256 of body; version is its first 12 hex + // characters, used as the ?v= cache buster. + etag string + version string +} + // builtinToolbarActions are the toolbar actions the framework implements // itself (D-14); a controller may not register an action with these names. var builtinToolbarActions = map[string]bool{"create": true, "delete": true} @@ -45,6 +67,9 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err) } cc.Actions = actions + if err := compileClientAssets(pluginID, cc, fsys); err != nil { + return err + } if cc.Form == nil { return nil } @@ -83,10 +108,84 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error } } field.ActionLabel = action.Label + if len(cc.scripts) == 0 { + return bootErr(pluginID, id, file, fmt.Errorf("field %s: a widget needs the controller to declare its JS through pact.AdminClientAssets", field.Name)) + } } return nil } +// compileClientAssets reads and hashes the files a controller declares +// through pact.AdminClientAssets. Each path must be clean, live under +// assets/, carry a JS (.js, .mjs) or CSS (.css) extension and exist in the +// plugin's embedded tree; there is no disk override. +func compileClientAssets(pluginID string, cc *CompiledController, fsys fs.FS) error { + src, ok := cc.Controller.(pact.AdminClientAssets) + if !ok || src == nil { + return nil + } + id := cc.Controller.ID() + vendor, plugin, found := strings.Cut(pluginID, ".") + if !found || !assetSegment.MatchString(vendor) || !assetSegment.MatchString(plugin) { + return fmt.Errorf("cabana: admin controller %s/%s: plugin ID must be vendor.plugin to serve admin assets", pluginID, id) + } + read := func(files []string, exts ...string) ([]*pluginAsset, error) { + out := make([]*pluginAsset, 0, len(files)) + seen := map[string]bool{} + for _, name := range files { + if err := checkAssetPath(name, exts); err != nil { + return nil, bootErr(pluginID, id, name, err) + } + if seen[name] { + return nil, bootErr(pluginID, id, name, fmt.Errorf("asset declared twice")) + } + seen[name] = true + body, err := fs.ReadFile(fsys, name) + if err != nil { + return nil, bootErr(pluginID, id, name, fmt.Errorf("asset is not in the plugin's embedded files: %w", err)) + } + sum := sha256.Sum256(body) + digest := hex.EncodeToString(sum[:]) + out = append(out, &pluginAsset{ + key: vendor + "/" + plugin + "/" + strings.TrimPrefix(name, "assets/"), + body: body, + contentType: boardwalk.ContentType(name), + etag: `"` + digest + `"`, + version: digest[:12], + }) + } + return out, nil + } + scripts, err := read(src.AdminJS(), ".js", ".mjs") + if err != nil { + return err + } + styles, err := read(src.AdminCSS(), ".css") + if err != nil { + return err + } + cc.scripts, cc.styles = scripts, styles + return nil +} + +func checkAssetPath(name string, exts []string) error { + if name != path.Clean(name) || !strings.HasPrefix(name, "assets/") || len(name) == len("assets/") { + return fmt.Errorf("asset path must be a clean path under assets/") + } + for _, segment := range strings.Split(name, "/") { + if segment == ".." || segment == "" { + return fmt.Errorf("asset path must be a clean path under assets/") + } + } + ext := strings.ToLower(path.Ext(name)) + for _, want := range exts { + if ext == want { + return nil + } + } + return fmt.Errorf("asset must end in %s", strings.Join(exts, " or ")) +} + func checkWidgetTag(tag, prefix string) error { if !widgetTagPattern.MatchString(tag) { return fmt.Errorf("widget %q is not a valid custom-element name (lowercase, with a hyphen)", tag) diff --git a/modules/cabana/http.go b/modules/cabana/http.go index 613ec6d..74f99a9 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -223,6 +223,9 @@ func (s *service) mount(r pact.Router) { g.Post("/{vendor}/{plugin}/{controller}/widgets/{field}", requireAjax(s.widgetAction)) constrainController(g) g.Where("field", "[A-Za-z_][A-Za-z0-9_]*") + g.Post("/{vendor}/{plugin}/{controller}/toolbar/{action}", requireAjax(s.toolbarAction)) + constrainController(g) + g.Where("action", "[A-Za-z_][A-Za-z0-9_]*") g.Get("/{vendor}/{plugin}/{controller}/{id}", s.show) constrainController(g) g.Put("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.update)) @@ -244,6 +247,11 @@ func (s *service) mount(r pact.Router) { // The SPA shell: public, no guard. ServeMux prefers every API pattern // above over the {path...} wildcard. r.GroupRaw(s.adminPrefix(), nil, func(g pact.Router) { + // Declared plugin JS and CSS (D-16); a miss falls through to the SPA, + // which serves its own dist assets under the same /assets/ path. + g.Get("/assets/{vendor}/{plugin}/{file...}", s.pluginAsset) + g.Where("vendor", "[A-Za-z0-9_-]+") + g.Where("plugin", "[A-Za-z0-9_-]+") g.Get("", s.serveSPA) g.Get("/{path...}", s.serveSPA) }) @@ -509,6 +517,7 @@ func (s *service) formSchema(w http.ResponseWriter, r *http.Request) { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } + view.Assets = s.controllerAssets(cc) meta := map[string]any{} if view.Meta.Locale != "" { meta["locale"] = view.Meta.Locale @@ -528,6 +537,16 @@ func (s *service) listSchema(w http.ResponseWriter, r *http.Request) { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } + // Only the registered toolbar actions this admin may run are offered. + principal, _ := bouncer.User(r.Context()) + allowed := make([]ToolbarAction, 0, len(view.ToolbarActions)) + for _, action := range view.ToolbarActions { + if registered, ok := cc.Actions[action.Name]; ok && Allows(principal, registered.Permissions) { + allowed = append(allowed, action) + } + } + view.ToolbarActions = allowed + view.Assets = s.controllerAssets(cc) meta := map[string]any{} if view.Meta != nil { meta["locale"] = view.Meta.Locale diff --git a/modules/cabana/list_schema.go b/modules/cabana/list_schema.go index 618af43..cbb8963 100644 --- a/modules/cabana/list_schema.go +++ b/modules/cabana/list_schema.go @@ -125,7 +125,7 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("unsupported search mode %s", mode)) } } - buttons, err := compileToolbarButtons(doc.Toolbar, doc.ShowCheckboxes) + buttons, toolbarActions, err := compileToolbarButtons(ctl, doc.Toolbar, doc.ShowCheckboxes) if err != nil { return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) } @@ -171,6 +171,7 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc SearchPrompt: prompt, DefaultSort: sort, ToolbarButtons: buttons, + ToolbarActions: toolbarActions, Columns: columns, Filters: filters, RowActions: rowActions, @@ -278,16 +279,15 @@ func compilePageOptions(recordsPerPage int, declared []int) ([]int, error) { return options, nil } -// toolbarButtons is the declarative toolbar.buttons list (D-14): built-in -// actions in display order. A scalar (Winter's partial name) is rejected -// with a pointer at the list syntax. +// toolbarButtons is the declarative toolbar.buttons list (D-14, D-12): +// action names in display order. The built-in create and delete sit next to +// names the controller registers through pact.HasAdminActions; decode has no +// controller, so membership is resolved in compileToolbarButtons. A scalar +// (Winter's partial name) is rejected with a pointer at the list syntax. type toolbarButtons struct { items []string } -// toolbarActions are the built-in toolbar actions; custom actions are Phase 10.1. -var toolbarActions = map[string]struct{}{"create": {}, "delete": {}} - func (b *toolbarButtons) UnmarshalYAML(node ast.Node) error { node = unwrapNode(node) switch n := node.(type) { @@ -301,10 +301,7 @@ func (b *toolbarButtons) UnmarshalYAML(node ast.Node) error { for _, item := range values { action, err := nodeString(unwrapNode(item)) if err != nil { - return fmt.Errorf("toolbar.buttons entries must be action names (create, delete)") - } - if _, ok := toolbarActions[action]; !ok { - return fmt.Errorf("toolbar.buttons: unsupported action %s (want create or delete)", action) + return fmt.Errorf("toolbar.buttons entries must be action names") } if _, dup := seen[action]; dup { return fmt.Errorf("toolbar.buttons: duplicate action %s", action) @@ -315,21 +312,43 @@ func (b *toolbarButtons) UnmarshalYAML(node ast.Node) error { b.items = items return nil default: - return fmt.Errorf("toolbar.buttons must be a list of actions (create, delete); the Winter partial %q is not supported", nodeText(node)) + return fmt.Errorf("toolbar.buttons must be a list of actions (create, delete or registered actions); the Winter partial %q is not supported", nodeText(node)) } } -func compileToolbarButtons(toolbar *listToolbar, showCheckboxes bool) ([]string, error) { +// compileToolbarButtons resolves every toolbar.buttons name against the +// built-in create and delete and the controller's registered actions: the one +// action namespace widgets use too. It returns the names in declared order and +// the registered entries with their (source) labels. +func compileToolbarButtons(ctl pact.AdminController, toolbar *listToolbar, showCheckboxes bool) ([]string, []ToolbarAction, error) { if toolbar == nil || len(toolbar.Buttons.items) == 0 { - return []string{}, nil + return []string{}, []ToolbarAction{}, nil } - out := append([]string(nil), toolbar.Buttons.items...) - for _, action := range out { - if action == "delete" && !showCheckboxes { - return nil, fmt.Errorf("toolbar.buttons: delete needs showCheckboxes: true") + registered := map[string]pact.AdminAction{} + if src, ok := ctl.(pact.HasAdminActions); ok && src != nil { + for _, action := range src.AdminActions() { + registered[action.Name] = action } } - return out, nil + out := append([]string(nil), toolbar.Buttons.items...) + custom := []ToolbarAction{} + for _, name := range out { + if builtinToolbarActions[name] { + if name == "delete" && !showCheckboxes { + return nil, nil, fmt.Errorf("toolbar.buttons: delete needs showCheckboxes: true") + } + continue + } + action, ok := registered[name] + if !ok { + return nil, nil, fmt.Errorf("toolbar.buttons: unsupported action %s (want create, delete or an action the controller registers)", name) + } + if strings.TrimSpace(action.Label) == "" { + return nil, nil, fmt.Errorf("toolbar.buttons: action %s needs a label", name) + } + custom = append(custom, ToolbarAction{Name: name, Label: action.Label}) + } + return out, custom, nil } func compileDefaultSort(doc *listSortDocument, columns []ListColumn) (*ListSort, error) { @@ -433,6 +452,15 @@ func (s *ListSchema) Localize(ctx context.Context, tr *phrasebook.Translator) (* out.SearchPrompt = translateKey(ctx, tr, s.SearchPrompt) out.PerPageOptions = append([]int(nil), s.PerPageOptions...) out.ToolbarButtons = append([]string(nil), s.ToolbarButtons...) + out.ToolbarActions = make([]ToolbarAction, len(s.ToolbarActions)) + for i, action := range s.ToolbarActions { + action.Label = translateKey(ctx, tr, action.Label) + out.ToolbarActions[i] = action + } + out.Assets = ControllerAssets{ + Scripts: append([]string{}, s.Assets.Scripts...), + Styles: append([]string{}, s.Assets.Styles...), + } if s.DefaultSort != nil { sort := *s.DefaultSort out.DefaultSort = &sort diff --git a/modules/cabana/list_schema_test.go b/modules/cabana/list_schema_test.go index 19e93c5..a0c0def 100644 --- a/modules/cabana/list_schema_test.go +++ b/modules/cabana/list_schema_test.go @@ -37,7 +37,7 @@ toolbar: prompt: backend::lang.list.search_prompt ` -const allColumnsJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordUrl":"acme/demo/widgets/update/:id","noRecordsMessage":"backend::lang.list.no_records","recordsPerPage":20,"perPageOptions":[20,50,100],"showSearch":true,"showSetup":true,"showCheckboxes":true,"showSorting":true,"searchTerm":"search","searchPrompt":"backend::lang.list.search_prompt","defaultSort":{"column":"name","direction":"asc"},"toolbarButtons":["create","delete"],"columns":[{"key":"name","label":"demo.lang.name","searchable":true,"sortable":true,"type":"text"},{"key":"created_at","label":"demo.lang.created","searchable":false,"sortable":true,"type":"datetime"},{"key":"active","label":"demo.lang.active","searchable":false,"sortable":false,"type":"switch"},{"key":"genre","label":"demo.lang.genre","searchable":true,"sortable":false,"relation":"genre","select":"name"}],"filters":[],"rowActions":[{"name":"update","label":"backend::lang.form.update","url":"acme/demo/widgets/update/:id"}],"bulkActions":[{"name":"delete","label":"backend::lang.list.delete_selected"}],` + defaultListMessagesJSON + `}` +const allColumnsJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordUrl":"acme/demo/widgets/update/:id","noRecordsMessage":"backend::lang.list.no_records","recordsPerPage":20,"perPageOptions":[20,50,100],"showSearch":true,"showSetup":true,"showCheckboxes":true,"showSorting":true,"searchTerm":"search","searchPrompt":"backend::lang.list.search_prompt","defaultSort":{"column":"name","direction":"asc"},"toolbarButtons":["create","delete"],"toolbarActions":[],"assets":{"scripts":[],"styles":[]},"columns":[{"key":"name","label":"demo.lang.name","searchable":true,"sortable":true,"type":"text"},{"key":"created_at","label":"demo.lang.created","searchable":false,"sortable":true,"type":"datetime"},{"key":"active","label":"demo.lang.active","searchable":false,"sortable":false,"type":"switch"},{"key":"genre","label":"demo.lang.genre","searchable":true,"sortable":false,"relation":"genre","select":"name"}],"filters":[],"rowActions":[{"name":"update","label":"backend::lang.form.update","url":"acme/demo/widgets/update/:id"}],"bulkActions":[{"name":"delete","label":"backend::lang.list.delete_selected"}],` + defaultListMessagesJSON + `}` // defaultListMessagesJSON is a compiled list's messages block when the YAML // declares none: every key is a framework default phrase key (D-13). @@ -56,7 +56,7 @@ func TestListSchemaCompile(t *testing.T) { for _, want := range []string{ `"searchable":false`, `"sortable":false`, `"type":"datetime"`, `"type":"switch"`, `"relation":"genre"`, `"select":"name"`, `"searchTerm":"search"`, `"showSetup":true`, - `"perPageOptions":[20,50,100]`, `"toolbarButtons":["create","delete"]`, `"filters":[]`, + `"perPageOptions":[20,50,100]`, `"toolbarButtons":["create","delete"],"toolbarActions":[],"assets":{"scripts":[],"styles":[]}`, `"filters":[]`, } { if !strings.Contains(got, want) { t.Fatalf("compiled list missing %s:\n%s", want, got) @@ -102,7 +102,7 @@ modelClass: Widget recordsPerPage: 20 ` got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns: {}\n") - want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[],"filters":[],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}` + want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"toolbarActions":[],"assets":{"scripts":[],"styles":[]},"columns":[],"filters":[],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}` if got != want { t.Fatalf("empty list =\n%s\nwant\n%s", got, want) } @@ -119,7 +119,7 @@ modelClass: Widget recordsPerPage: 20 ` got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns:\n name:\n label: Name\n searchable: true\n") - want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}` + want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"toolbarActions":[],"assets":{"scripts":[],"styles":[]},"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}` if got != want { t.Fatalf("single list =\n%s\nwant\n%s", got, want) } @@ -311,7 +311,7 @@ const filterColumns = `columns: searchable: true ` -const allFiltersJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[{"name":"activated","label":"demo.lang.activated","type":"switch","column":"active","options":[{"value":true,"label":"demo.lang.yes"},{"value":false,"label":"demo.lang.no"}]},{"name":"created","label":"demo.lang.created_range","type":"daterange","column":"created_at"},{"name":"grouped","label":"demo.lang.grouped","type":"scope","scope":"filterByGroup","modelClass":"Group","nameFrom":"name"}],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}` +const allFiltersJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"toolbarActions":[],"assets":{"scripts":[],"styles":[]},"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[{"name":"activated","label":"demo.lang.activated","type":"switch","column":"active","options":[{"value":true,"label":"demo.lang.yes"},{"value":false,"label":"demo.lang.no"}]},{"name":"created","label":"demo.lang.created_range","type":"daterange","column":"created_at"},{"name":"grouped","label":"demo.lang.grouped","type":"scope","scope":"filterByGroup","modelClass":"Group","nameFrom":"name"}],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}` func TestListSchemaFilter(t *testing.T) { filters := readListFixture(t, "testdata/list/all_filters.yaml") diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go index 665fd96..b7083de 100644 --- a/modules/cabana/openapi_conformance_test.go +++ b/modules/cabana/openapi_conformance_test.go @@ -85,7 +85,20 @@ func TestPhase10OpenAPIConformance(t *testing.T) { return e.send(t, http.MethodPut, "/settings/conform", map[string]any{"enabled": true}, true) }, into[cabana.Envelope[cabana.SettingsResult]]()}, {"GET /{vendor}/{plugin}/{controller}/schema/list", 200, "cabana.Envelope-cabana_ListSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { - return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/list", nil, true) + rec := e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/list", nil, true) + var body cabana.Envelope[cabana.ListSchema] + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("list schema: %v", err) + } + if len(body.Data.ToolbarActions) != 1 || body.Data.ToolbarActions[0].Name != "recount" { + t.Fatalf("toolbarActions = %#v", body.Data.ToolbarActions) + } + if len(body.Data.Assets.Scripts) != 1 || len(body.Data.Assets.Styles) != 1 { + t.Fatalf("assets = %#v", body.Data.Assets) + } + e.assertPluginAsset(t, body.Data.Assets.Scripts[0], "text/javascript; charset=utf-8") + e.assertPluginAsset(t, body.Data.Assets.Styles[0], "text/css; charset=utf-8") + return rec }, into[cabana.Envelope[cabana.ListSchema]]()}, {"GET /{vendor}/{plugin}/{controller}/schema/form", 200, "cabana.Envelope-cabana_FormView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/form", nil, true) @@ -138,6 +151,9 @@ func TestPhase10OpenAPIConformance(t *testing.T) { {"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/unlink", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true) }, into[cabana.Envelope[cabana.RelationMutationResult]]()}, + {"POST /{vendor}/{plugin}/{controller}/toolbar/{action}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodPost, "/acme/conform/gadgets/toolbar/recount", map[string]any{}, true) + }, into[cabana.Envelope[cabana.AdminActionResult]]()}, {"POST /{vendor}/{plugin}/{controller}/bulk-delete", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { spare := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "spare-" + e.stamp}, true) return e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk-delete", map[string]any{"ids": []uint{dataID(t, spare.Body.Bytes())}}, true) @@ -261,6 +277,35 @@ func (e *conformEnv) send(t *testing.T, method, rel string, body any, auth bool) return rec } +// assertPluginAsset fetches a schema asset URL through the assembled router +// and checks the D-16 headers; an undeclared file under the same directory +// must fall through to the SPA's 404. +func (e *conformEnv) assertPluginAsset(t *testing.T, url, contentType string) { + t.Helper() + path, version, ok := strings.Cut(url, "?v=") + if !ok || !strings.HasPrefix(path, cabana.DefaultAdminPrefix+"/assets/acme/conform/") || len(version) != 12 { + t.Fatalf("asset url %q", url) + } + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, url, nil)) + h := rec.Header() + if rec.Code != http.StatusOK || h.Get("Content-Type") != contentType || h.Get("X-Content-Type-Options") != "nosniff" || + h.Get("Cross-Origin-Resource-Policy") != "same-origin" || h.Get("Cache-Control") != "no-cache" || + !strings.HasPrefix(h.Get("ETag"), `"`+version) || !strings.Contains(h.Get("Content-Security-Policy"), "script-src 'self'") { + t.Fatalf("asset %s status=%d headers=%v", url, rec.Code, h) + } + miss := httptest.NewRecorder() + e.h.ServeHTTP(miss, httptest.NewRequest(http.MethodGet, cabana.DefaultAdminPrefix+"/assets/acme/conform/../controllers/gadgets/config_list.yaml", nil)) + if miss.Code == http.StatusOK && strings.Contains(miss.Body.String(), "modelClass") { + t.Fatalf("plugin YAML leaked through the asset route") + } + undeclared := httptest.NewRecorder() + e.h.ServeHTTP(undeclared, httptest.NewRequest(http.MethodGet, cabana.DefaultAdminPrefix+"/assets/acme/conform/js/other.js", nil)) + if undeclared.Code != http.StatusNotFound { + t.Fatalf("undeclared asset status=%d", undeclared.Code) + } +} + func dataID(t *testing.T, raw []byte) uint { t.Helper() var body struct { @@ -423,8 +468,15 @@ func (c conformController) AdminActions() []pact.AdminAction { Run: func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) { return pact.AdminActionResult{Message: "Looked up", Fill: map[string]any{"name": "lookup-" + c.stamp, "active": true}}, nil }, + }, { + Name: "recount", Label: "Recount", Permissions: []string{"acme.conform.access"}, + Run: func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) { + return pact.AdminActionResult{Message: "Recounted"}, nil + }, }} } +func (conformController) AdminJS() []string { return []string{"assets/js/lookup.js"} } +func (conformController) AdminCSS() []string { return []string{"assets/css/gadgets.css"} } func conformFS() fs.FS { file := func(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} } @@ -437,9 +489,30 @@ recordsPerPage: 20 showCheckboxes: true filter: config_filter.yaml toolbar: - buttons: [create, delete] + buttons: [create, delete, recount] search: prompt: backend::lang.list.search_prompt +`), + // A plain custom element: a light-DOM button that asks the admin SPA + // to run the field's action. It makes no network call and never + // touches cookies; the SPA owns HTTP (D-05). + "assets/js/lookup.js": file(`class AcmeConformLookup extends HTMLElement { + connectedCallback() { + if (this.firstChild) return + const button = document.createElement('button') + button.type = 'button' + button.textContent = this.getAttribute('label') || 'Lookup' + button.addEventListener('click', () => { + this.dispatchEvent(new CustomEvent('summer-action', { bubbles: true, composed: true })) + }) + this.append(button) + } +} +if (!customElements.get('acme-conform-lookup')) { + customElements.define('acme-conform-lookup', AcmeConformLookup) +} +`), + "assets/css/gadgets.css": file(`acme-conform-lookup button { font: inherit; } `), "controllers/gadgets/config_filter.yaml": file(`scopes: grouped: diff --git a/modules/cabana/phase10_coverage_test.go b/modules/cabana/phase10_coverage_test.go index e9bb3e1..4925d74 100644 --- a/modules/cabana/phase10_coverage_test.go +++ b/modules/cabana/phase10_coverage_test.go @@ -94,6 +94,7 @@ func TestPhase10Coverage(t *testing.T) { "POST /auth/refresh", "POST /{vendor}/{plugin}/{controller}", "POST /{vendor}/{plugin}/{controller}/bulk-delete", + "POST /{vendor}/{plugin}/{controller}/toolbar/{action}", "POST /{vendor}/{plugin}/{controller}/widgets/{field}", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", @@ -101,7 +102,7 @@ func TestPhase10Coverage(t *testing.T) { "PUT /{vendor}/{plugin}/{controller}/{id}", } if strings.Join(unsafe, "\n") != strings.Join(want, "\n") { - t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 10 besides login):\n%s", strings.Join(unsafe, "\n")) + t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 11 besides login):\n%s", strings.Join(unsafe, "\n")) } // The routes added in Phase 10 are safe reads: GET /lang and the shared // nested pattern serving field options, filter options and relation lists. diff --git a/modules/cabana/phase10_csrf_test.go b/modules/cabana/phase10_csrf_test.go index 70c5b4c..1643fb7 100644 --- a/modules/cabana/phase10_csrf_test.go +++ b/modules/cabana/phase10_csrf_test.go @@ -67,9 +67,9 @@ func TestPhase10CSRF(t *testing.T) { }) } // refresh, logout, settings put, create, bulk-delete, widget action, - // update, delete, link, unlink - if unsafe != 10 { - t.Fatalf("walked %d state-changing routes, want 10: %v", unsafe, router.order) + // toolbar action, update, delete, link, unlink + if unsafe != 11 { + t.Fatalf("walked %d state-changing routes, want 11: %v", unsafe, router.order) } loginHandler := router.handlers[login] diff --git a/modules/cabana/plugin_assets.go b/modules/cabana/plugin_assets.go new file mode 100644 index 0000000..807c740 --- /dev/null +++ b/modules/cabana/plugin_assets.go @@ -0,0 +1,54 @@ +package cabana + +import ( + "bytes" + "net/http" + "path" + "time" + + "git.golem15.com/golem15/summercms/modules/boardwalk" +) + +// pluginAsset serves GET {prefix}/assets/{vendor}/{plugin}/{file...}: a +// controller's declared JS or CSS file, looked up by exact key in the map +// built at boot, so a plugin's embedded tree is never exposed wholesale and +// traversal matches no key. A miss falls through to the SPA handler, which +// serves the embedded dist assets and answers any other name with its 404. +// +// Plugin files are not content-hashed, so they are revalidated on every use +// (no-cache plus a sha256 ETag); the schema URLs carry a ?v= hash instead of +// the long-lived caching the SPA's hashed dist files get. +func (s *service) pluginAsset(w http.ResponseWriter, r *http.Request) { + var asset *pluginAsset + if s != nil && s.reg != nil { + asset = s.reg.assets[r.PathValue("vendor")+"/"+r.PathValue("plugin")+"/"+r.PathValue("file")] + } + if asset == nil { + s.serveSPA(w, r) + return + } + h := w.Header() + boardwalk.SetSecurityHeaders(h) + h.Set("Cross-Origin-Resource-Policy", "same-origin") + h.Set("Content-Type", asset.contentType) + h.Set("Cache-Control", "no-cache") + h.Set("ETag", asset.etag) + http.ServeContent(w, r, path.Base(asset.key), time.Time{}, bytes.NewReader(asset.body)) +} + +// controllerAssets are the same-origin URLs of a controller's plugin files, +// each with a ?v= content hash so a rebuilt binary never serves stale JS. +func (s *service) controllerAssets(cc *CompiledController) ControllerAssets { + out := ControllerAssets{Scripts: []string{}, Styles: []string{}} + if cc == nil { + return out + } + base := s.adminPrefix() + "/assets/" + for _, file := range cc.scripts { + out.Scripts = append(out.Scripts, base+file.key+"?v="+file.version) + } + for _, file := range cc.styles { + out.Styles = append(out.Styles, base+file.key+"?v="+file.version) + } + return out +} diff --git a/modules/cabana/registry.go b/modules/cabana/registry.go index bcc004d..cd1603e 100644 --- a/modules/cabana/registry.go +++ b/modules/cabana/registry.go @@ -52,24 +52,25 @@ func checkReservedSegments(items []controllerRef) error { func compileRegistry(items []controllerRef) (*Registry, error) { byID := make(map[string]*CompiledController, len(items)) + assets := map[string]*pluginAsset{} for _, item := range items { id := item.ctl.ID() if _, exists := byID[id]; exists { return nil, fmt.Errorf("cabana: duplicate admin controller %s", id) } - assets, ok := item.plugin.(pact.AdminAssets) - if !ok || assets == nil || assets.AdminFS() == nil { + fsys, ok := item.plugin.(pact.AdminAssets) + if !ok || fsys == nil || fsys.AdminFS() == nil { return nil, fmt.Errorf("cabana: plugin %s has admin controllers but no AdminFS", item.plugin.ID()) } - list, err := compileList(item.plugin.ID(), item.ctl, assets.AdminFS()) + list, err := compileList(item.plugin.ID(), item.ctl, fsys.AdminFS()) if err != nil { return nil, err } - form, err := compileFormIfPresent(item.plugin.ID(), item.ctl, assets.AdminFS()) + form, err := compileFormIfPresent(item.plugin.ID(), item.ctl, fsys.AdminFS()) if err != nil { return nil, err } - relations, err := compileRelations(item.plugin.ID(), item.ctl, assets.AdminFS(), form) + relations, err := compileRelations(item.plugin.ID(), item.ctl, fsys.AdminFS(), form) if err != nil { return nil, err } @@ -92,12 +93,18 @@ func compileRegistry(items []controllerRef) (*Registry, error) { if err := BindWritableFields(compiled); err != nil { return nil, err } - if err := compileExtension(item.plugin.ID(), compiled, assets.AdminFS()); err != nil { + if err := compileExtension(item.plugin.ID(), compiled, fsys.AdminFS()); err != nil { return nil, err } + // Two controllers of one plugin declaring the same file share an entry. + for _, file := range append(append([]*pluginAsset(nil), compiled.scripts...), compiled.styles...) { + if _, exists := assets[file.key]; !exists { + assets[file.key] = file + } + } byID[id] = compiled } - return &Registry{byID: byID}, nil + return &Registry{byID: byID, assets: assets}, nil } func withoutAction(actions []string, drop string) []string { diff --git a/modules/cabana/schema_types.go b/modules/cabana/schema_types.go index 1c5169b..48ae9ff 100644 --- a/modules/cabana/schema_types.go +++ b/modules/cabana/schema_types.go @@ -54,24 +54,29 @@ type BulkAction struct { // ListSchema is the boot-compiled list contract for one controller. // Labels stay as source keys until a request localizes a copy. type ListSchema struct { - Title string `json:"title,omitempty"` - ModelClass string `json:"modelClass,omitempty"` - RecordURL string `json:"recordUrl,omitempty"` - NoRecordsMessage string `json:"noRecordsMessage,omitempty"` - RecordsPerPage int `json:"recordsPerPage"` - PerPageOptions []int `json:"perPageOptions"` - ShowSearch bool `json:"showSearch"` - ShowSetup bool `json:"showSetup"` - ShowCheckboxes bool `json:"showCheckboxes"` - ShowSorting bool `json:"showSorting"` - SearchTerm string `json:"searchTerm"` - SearchPrompt string `json:"searchPrompt,omitempty"` - DefaultSort *ListSort `json:"defaultSort,omitempty"` - ToolbarButtons []string `json:"toolbarButtons"` - Columns []ListColumn `json:"columns"` - Filters []ListFilter `json:"filters"` - RowActions []RowAction `json:"rowActions"` - BulkActions []BulkAction `json:"bulkActions"` + Title string `json:"title,omitempty"` + ModelClass string `json:"modelClass,omitempty"` + RecordURL string `json:"recordUrl,omitempty"` + NoRecordsMessage string `json:"noRecordsMessage,omitempty"` + RecordsPerPage int `json:"recordsPerPage"` + PerPageOptions []int `json:"perPageOptions"` + ShowSearch bool `json:"showSearch"` + ShowSetup bool `json:"showSetup"` + ShowCheckboxes bool `json:"showCheckboxes"` + ShowSorting bool `json:"showSorting"` + SearchTerm string `json:"searchTerm"` + SearchPrompt string `json:"searchPrompt,omitempty"` + DefaultSort *ListSort `json:"defaultSort,omitempty"` + ToolbarButtons []string `json:"toolbarButtons"` + // ToolbarActions are the controller-registered toolbar.buttons entries + // the requesting admin may run, in declared order, with their labels. + ToolbarActions []ToolbarAction `json:"toolbarActions"` + // Assets are the controller's plugin JS and CSS URLs (pact.AdminClientAssets). + Assets ControllerAssets `json:"assets"` + Columns []ListColumn `json:"columns"` + Filters []ListFilter `json:"filters"` + RowActions []RowAction `json:"rowActions"` + BulkActions []BulkAction `json:"bulkActions"` // Messages is the list's copy (D-13). The cached schema carries each // phrase key as its own form; a response resolves them in its locale. Messages *ListMessages `json:"messages"` @@ -94,6 +99,9 @@ func (s ListSchema) MarshalJSON() ([]byte, error) { if out.ToolbarButtons == nil { out.ToolbarButtons = []string{} } + if out.ToolbarActions == nil { + out.ToolbarActions = []ToolbarAction{} + } if out.Columns == nil { out.Columns = []ListColumn{} } @@ -133,6 +141,36 @@ type FormView struct { // them onto its routes. Redirects FormRedirects `json:"redirects"` Meta FormMeta `json:"meta"` + // Assets are the controller's plugin JS and CSS URLs; a settings form + // carries empty lists. + Assets ControllerAssets `json:"assets"` +} + +// ControllerAssets are the same-origin URLs of a controller's plugin JS and +// CSS files, each carrying a ?v= content hash. Both lists are always arrays. +type ControllerAssets struct { + Scripts []string `json:"scripts"` + Styles []string `json:"styles"` +} + +// MarshalJSON keeps both lists arrays, never null. +func (a ControllerAssets) MarshalJSON() ([]byte, error) { + type alias ControllerAssets + out := alias(a) + if out.Scripts == nil { + out.Scripts = []string{} + } + if out.Styles == nil { + out.Styles = []string{} + } + return json.Marshal(out) +} + +// ToolbarAction is one controller-registered toolbar button (D-12): the +// action name posted to .../toolbar/{action} and its localized label. +type ToolbarAction struct { + Name string `json:"name"` + Label string `json:"label"` } // FormRedirects are config_form.yaml defaultRedirect, create.* and update.*. diff --git a/modules/cabana/security_coverage_test.go b/modules/cabana/security_coverage_test.go index 7635d52..5c48b7f 100644 --- a/modules/cabana/security_coverage_test.go +++ b/modules/cabana/security_coverage_test.go @@ -51,6 +51,7 @@ var phase09Routes = []adminRoute{ {key: "POST /{vendor}/{plugin}/{controller}"}, {key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"}, {key: "POST /{vendor}/{plugin}/{controller}/widgets/{field}"}, + {key: "POST /{vendor}/{plugin}/{controller}/toolbar/{action}"}, {key: "GET /{vendor}/{plugin}/{controller}/{id}"}, {key: "PUT /{vendor}/{plugin}/{controller}/{id}"}, {key: "DELETE /{vendor}/{plugin}/{controller}/{id}"}, @@ -58,6 +59,7 @@ var phase09Routes = []adminRoute{ {key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates"}, {key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link"}, {key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"}, + {key: "GET /assets/{vendor}/{plugin}/{file...}", public: true, spa: true}, {key: "GET ", public: true, spa: true}, {key: "GET /{path...}", public: true, spa: true}, } @@ -266,6 +268,7 @@ func phase09ProtectedCalls() []phase09Call { {"create", (*service).create}, {"bulk-delete", (*service).bulkDelete}, {"widget-action", (*service).widgetAction}, + {"toolbar-action", (*service).toolbarAction}, {"show", (*service).show}, {"update", (*service).update}, {"delete", (*service).deleteRecord},