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:
@@ -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.",
|
||||
|
||||
109
admin/src/api/schema.d.ts
vendored
109
admin/src/api/schema.d.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
4
admin/tests/fixtures/settings.json
vendored
4
admin/tests/fixtures/settings.json
vendored
@@ -75,6 +75,10 @@
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
},
|
||||
"assets": {
|
||||
"scripts": [],
|
||||
"styles": []
|
||||
},
|
||||
"redirects": {
|
||||
"create": {
|
||||
"redirect": "",
|
||||
|
||||
@@ -143,6 +143,10 @@
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
},
|
||||
"assets": {
|
||||
"scripts": [],
|
||||
"styles": []
|
||||
},
|
||||
"redirects": {
|
||||
"create": {
|
||||
"redirect": "acme/demo/widgets/update/:id",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
54
modules/cabana/plugin_assets.go
Normal file
54
modules/cabana/plugin_assets.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.*.
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user