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
This commit is contained in:
Jakub Zych
2026-09-28 23:41:17 +02:00
parent f9281949a6
commit 8b1cb244de
23 changed files with 772 additions and 64 deletions

View File

@@ -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.",

View File

@@ -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;
};

View File

@@ -75,6 +75,10 @@
"meta": {
"locale": "en"
},
"assets": {
"scripts": [],
"styles": []
},
"redirects": {
"create": {
"redirect": "",

View File

@@ -143,6 +143,10 @@
"meta": {
"locale": "en"
},
"assets": {
"scripts": [],
"styles": []
},
"redirects": {
"create": {
"redirect": "acme/demo/widgets/update/:id",

View File

@@ -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 },

View File

@@ -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

View File

@@ -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")

View File

@@ -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)
}
})

View File

@@ -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 `<prefix>/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 <prefix>/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 `<prefix>/assets/acme/blog/js/lookup.js`, and the schemas list it as `<prefix>/assets/acme/blog/js/lookup.js?v=<first 12 hex characters of its sha256>`. 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 `<prefix>/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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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/<path after assets/>); the asset route serves only these.
assets map[string]*pluginAsset
}
// Get returns the compiled controller for a D-09 id (vendor.plugin.controller).

View File

@@ -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/<path after assets/>, 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)

View File

@@ -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

View File

@@ -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
}
registered := map[string]pact.AdminAction{}
if src, ok := ctl.(pact.HasAdminActions); ok && src != nil {
for _, action := range src.AdminActions() {
registered[action.Name] = action
}
}
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")
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
}
return out, nil
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

View File

@@ -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")

View File

@@ -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:

View File

@@ -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.

View File

@@ -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]

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -68,6 +68,11 @@ type ListSchema struct {
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"`
@@ -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.*.

View File

@@ -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},