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"
|
"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": {
|
"cabana.Envelope-array_cabana_FilterOption": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -609,6 +630,14 @@
|
|||||||
},
|
},
|
||||||
"cabana.FormView": {
|
"cabana.FormView": {
|
||||||
"properties": {
|
"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": {
|
"fields": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/cabana.FormField"
|
"$ref": "#/components/schemas/cabana.FormField"
|
||||||
@@ -642,6 +671,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"assets",
|
||||||
"fields",
|
"fields",
|
||||||
"messages",
|
"messages",
|
||||||
"meta",
|
"meta",
|
||||||
@@ -838,6 +868,14 @@
|
|||||||
},
|
},
|
||||||
"cabana.ListSchema": {
|
"cabana.ListSchema": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"assets": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/cabana.ControllerAssets"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Assets are the controller's plugin JS and CSS URLs (pact.AdminClientAssets)."
|
||||||
|
},
|
||||||
"bulkActions": {
|
"bulkActions": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/cabana.BulkAction"
|
"$ref": "#/components/schemas/cabana.BulkAction"
|
||||||
@@ -915,6 +953,13 @@
|
|||||||
"title": {
|
"title": {
|
||||||
"type": "string"
|
"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": {
|
"toolbarButtons": {
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -923,6 +968,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"assets",
|
||||||
"bulkActions",
|
"bulkActions",
|
||||||
"columns",
|
"columns",
|
||||||
"filters",
|
"filters",
|
||||||
@@ -935,6 +981,7 @@
|
|||||||
"showSearch",
|
"showSearch",
|
||||||
"showSetup",
|
"showSetup",
|
||||||
"showSorting",
|
"showSorting",
|
||||||
|
"toolbarActions",
|
||||||
"toolbarButtons"
|
"toolbarButtons"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -1277,6 +1324,21 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.ToolbarAction": {
|
||||||
|
"properties": {
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"label",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.fieldContext": {
|
"cabana.fieldContext": {
|
||||||
"oneOf": [
|
"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}": {
|
"/{vendor}/{plugin}/{controller}/widgets/{field}": {
|
||||||
"post": {
|
"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.",
|
"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;
|
patch?: never;
|
||||||
trace?: 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}": {
|
"/{vendor}/{plugin}/{controller}/widgets/{field}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1953,6 +2042,10 @@ export interface components {
|
|||||||
"cabana.BulkResult": {
|
"cabana.BulkResult": {
|
||||||
deleted: number;
|
deleted: number;
|
||||||
};
|
};
|
||||||
|
"cabana.ControllerAssets": {
|
||||||
|
scripts: string[];
|
||||||
|
styles: string[];
|
||||||
|
};
|
||||||
"cabana.Envelope-array_cabana_FilterOption": {
|
"cabana.Envelope-array_cabana_FilterOption": {
|
||||||
data: components["schemas"]["cabana.FilterOption"][];
|
data: components["schemas"]["cabana.FilterOption"][];
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
@@ -2076,6 +2169,11 @@ export interface components {
|
|||||||
update: components["schemas"]["cabana.FormRedirect"];
|
update: components["schemas"]["cabana.FormRedirect"];
|
||||||
};
|
};
|
||||||
"cabana.FormView": {
|
"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"][];
|
fields: components["schemas"]["cabana.FormField"][];
|
||||||
/** @description Messages is the form's copy resolved in the request locale (D-13). */
|
/** @description Messages is the form's copy resolved in the request locale (D-13). */
|
||||||
messages: components["schemas"]["cabana.FormMessages"];
|
messages: components["schemas"]["cabana.FormMessages"];
|
||||||
@@ -2141,6 +2239,8 @@ export interface components {
|
|||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
"cabana.ListSchema": {
|
"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"][];
|
bulkActions: components["schemas"]["cabana.BulkAction"][];
|
||||||
columns: components["schemas"]["cabana.ListColumn"][];
|
columns: components["schemas"]["cabana.ListColumn"][];
|
||||||
defaultSort?: components["schemas"]["cabana.ListSort"];
|
defaultSort?: components["schemas"]["cabana.ListSort"];
|
||||||
@@ -2164,6 +2264,11 @@ export interface components {
|
|||||||
showSetup: boolean;
|
showSetup: boolean;
|
||||||
showSorting: boolean;
|
showSorting: boolean;
|
||||||
title?: string;
|
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[];
|
toolbarButtons: string[];
|
||||||
};
|
};
|
||||||
"cabana.ListSort": {
|
"cabana.ListSort": {
|
||||||
@@ -2261,6 +2366,10 @@ export interface components {
|
|||||||
per_page?: number;
|
per_page?: number;
|
||||||
total?: number;
|
total?: number;
|
||||||
};
|
};
|
||||||
|
"cabana.ToolbarAction": {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
"cabana.fieldContext": string | string[];
|
"cabana.fieldContext": string | string[];
|
||||||
"cabana.jsonScalar": (string | number | boolean) | null;
|
"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": {
|
"meta": {
|
||||||
"locale": "en"
|
"locale": "en"
|
||||||
},
|
},
|
||||||
|
"assets": {
|
||||||
|
"scripts": [],
|
||||||
|
"styles": []
|
||||||
|
},
|
||||||
"redirects": {
|
"redirects": {
|
||||||
"create": {
|
"create": {
|
||||||
"redirect": "",
|
"redirect": "",
|
||||||
|
|||||||
@@ -143,6 +143,10 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"locale": "en"
|
"locale": "en"
|
||||||
},
|
},
|
||||||
|
"assets": {
|
||||||
|
"scripts": [],
|
||||||
|
"styles": []
|
||||||
|
},
|
||||||
"redirects": {
|
"redirects": {
|
||||||
"create": {
|
"create": {
|
||||||
"redirect": "acme/demo/widgets/update/:id",
|
"redirect": "acme/demo/widgets/update/:id",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
"searchTerm": "",
|
"searchTerm": "",
|
||||||
"recordUrl": "acme/demo/widgets/update/:id",
|
"recordUrl": "acme/demo/widgets/update/:id",
|
||||||
"toolbarButtons": ["create", "delete"],
|
"toolbarButtons": ["create", "delete"],
|
||||||
|
"toolbarActions": [],
|
||||||
|
"assets": { "scripts": [], "styles": [] },
|
||||||
"columns": [
|
"columns": [
|
||||||
{ "key": "name", "label": "Name", "searchable": true, "sortable": true },
|
{ "key": "name", "label": "Name", "searchable": true, "sortable": true },
|
||||||
{ "key": "code", "label": "Code", "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.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.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.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
|
## Dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ type handler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
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(r.URL.Path, h.prefix)
|
||||||
rel = strings.TrimPrefix(rel, "/")
|
rel = strings.TrimPrefix(rel, "/")
|
||||||
if rel == "api" || strings.HasPrefix(rel, "api/") {
|
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)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", contentType(name))
|
w.Header().Set("Content-Type", ContentType(name))
|
||||||
if strings.HasPrefix(name, "assets/") {
|
if strings.HasPrefix(name, "assets/") {
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
} else {
|
} 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))
|
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))
|
ext := strings.ToLower(path.Ext(name))
|
||||||
if ct, ok := contentTypes[ext]; ok {
|
if ct, ok := contentTypes[ext]; ok {
|
||||||
return ct
|
return ct
|
||||||
@@ -158,7 +161,10 @@ func contentType(name string) string {
|
|||||||
return "application/octet-stream"
|
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("X-Content-Type-Options", "nosniff")
|
||||||
h.Set("Referrer-Policy", "same-origin")
|
h.Set("Referrer-Policy", "same-origin")
|
||||||
h.Set("X-Frame-Options", "DENY")
|
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") {
|
if index.Header().Get("Cache-Control") != "no-store" || !strings.HasPrefix(index.Header().Get("Content-Type"), "text/html") {
|
||||||
t.Fatalf("index headers = %v", index.Header())
|
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)
|
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)
|
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)
|
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" {
|
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)
|
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)
|
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.
|
- 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.
|
- 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.
|
- 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`.
|
- 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.
|
- 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.
|
- 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. |
|
| 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}/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}/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 `.../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. |
|
| 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. |
|
| 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.
|
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
|
## Usage
|
||||||
|
|
||||||
A plugin exposes an admin controller and embeds its YAML. `summer make:admin-controller` scaffolds the controller type and its four YAML files:
|
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.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.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.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
|
## 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).
|
- 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).
|
- 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.
|
- Tests additionally use `github.com/testcontainers/testcontainers-go` and its `modules/postgres` package.
|
||||||
|
|
||||||
## Testing
|
## 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
|
// allowAction applies the action's own permissions on top of the controller's
|
||||||
// (already checked by protect). A denial is logged and answered 403.
|
// (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 {
|
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]
|
// @Router /{vendor}/{plugin}/{controller}/widgets/{field} [post]
|
||||||
func AdminWidgetAction() {}
|
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.
|
// AdminShow documents the record show route.
|
||||||
//
|
//
|
||||||
// @Summary Show an admin record
|
// @Summary Show an admin record
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ type CompiledController struct {
|
|||||||
// the single namespace that toolbar.buttons names and widget action: keys
|
// the single namespace that toolbar.buttons names and widget action: keys
|
||||||
// resolve through. create and delete are reserved built-in names.
|
// resolve through. create and delete are reserved built-in names.
|
||||||
Actions map[string]pact.AdminAction
|
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.
|
// Registry is the immutable controller map keyed by controller ID.
|
||||||
@@ -86,6 +91,9 @@ type Registry struct {
|
|||||||
roleGrants map[string]map[string]bool
|
roleGrants map[string]map[string]bool
|
||||||
navigation []pact.NavigationItem
|
navigation []pact.NavigationItem
|
||||||
settings map[string]*CompiledSetting
|
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).
|
// Get returns the compiled controller for a D-09 id (vendor.plugin.controller).
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package cabana
|
package cabana
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"path"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/boardwalk"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"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,
|
"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
|
// builtinToolbarActions are the toolbar actions the framework implements
|
||||||
// itself (D-14); a controller may not register an action with these names.
|
// itself (D-14); a controller may not register an action with these names.
|
||||||
var builtinToolbarActions = map[string]bool{"create": true, "delete": true}
|
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)
|
return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err)
|
||||||
}
|
}
|
||||||
cc.Actions = actions
|
cc.Actions = actions
|
||||||
|
if err := compileClientAssets(pluginID, cc, fsys); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if cc.Form == nil {
|
if cc.Form == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -83,10 +108,84 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
field.ActionLabel = action.Label
|
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
|
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 {
|
func checkWidgetTag(tag, prefix string) error {
|
||||||
if !widgetTagPattern.MatchString(tag) {
|
if !widgetTagPattern.MatchString(tag) {
|
||||||
return fmt.Errorf("widget %q is not a valid custom-element name (lowercase, with a hyphen)", 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))
|
g.Post("/{vendor}/{plugin}/{controller}/widgets/{field}", requireAjax(s.widgetAction))
|
||||||
constrainController(g)
|
constrainController(g)
|
||||||
g.Where("field", "[A-Za-z_][A-Za-z0-9_]*")
|
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)
|
g.Get("/{vendor}/{plugin}/{controller}/{id}", s.show)
|
||||||
constrainController(g)
|
constrainController(g)
|
||||||
g.Put("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.update))
|
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
|
// The SPA shell: public, no guard. ServeMux prefers every API pattern
|
||||||
// above over the {path...} wildcard.
|
// above over the {path...} wildcard.
|
||||||
r.GroupRaw(s.adminPrefix(), nil, func(g pact.Router) {
|
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("", s.serveSPA)
|
||||||
g.Get("/{path...}", 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)
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
view.Assets = s.controllerAssets(cc)
|
||||||
meta := map[string]any{}
|
meta := map[string]any{}
|
||||||
if view.Meta.Locale != "" {
|
if view.Meta.Locale != "" {
|
||||||
meta["locale"] = 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)
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
return
|
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{}
|
meta := map[string]any{}
|
||||||
if view.Meta != nil {
|
if view.Meta != nil {
|
||||||
meta["locale"] = view.Meta.Locale
|
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))
|
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 {
|
if err != nil {
|
||||||
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
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,
|
SearchPrompt: prompt,
|
||||||
DefaultSort: sort,
|
DefaultSort: sort,
|
||||||
ToolbarButtons: buttons,
|
ToolbarButtons: buttons,
|
||||||
|
ToolbarActions: toolbarActions,
|
||||||
Columns: columns,
|
Columns: columns,
|
||||||
Filters: filters,
|
Filters: filters,
|
||||||
RowActions: rowActions,
|
RowActions: rowActions,
|
||||||
@@ -278,16 +279,15 @@ func compilePageOptions(recordsPerPage int, declared []int) ([]int, error) {
|
|||||||
return options, nil
|
return options, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolbarButtons is the declarative toolbar.buttons list (D-14): built-in
|
// toolbarButtons is the declarative toolbar.buttons list (D-14, D-12):
|
||||||
// actions in display order. A scalar (Winter's partial name) is rejected
|
// action names in display order. The built-in create and delete sit next to
|
||||||
// with a pointer at the list syntax.
|
// 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 {
|
type toolbarButtons struct {
|
||||||
items []string
|
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 {
|
func (b *toolbarButtons) UnmarshalYAML(node ast.Node) error {
|
||||||
node = unwrapNode(node)
|
node = unwrapNode(node)
|
||||||
switch n := node.(type) {
|
switch n := node.(type) {
|
||||||
@@ -301,10 +301,7 @@ func (b *toolbarButtons) UnmarshalYAML(node ast.Node) error {
|
|||||||
for _, item := range values {
|
for _, item := range values {
|
||||||
action, err := nodeString(unwrapNode(item))
|
action, err := nodeString(unwrapNode(item))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("toolbar.buttons entries must be action names (create, delete)")
|
return fmt.Errorf("toolbar.buttons entries must be action names")
|
||||||
}
|
|
||||||
if _, ok := toolbarActions[action]; !ok {
|
|
||||||
return fmt.Errorf("toolbar.buttons: unsupported action %s (want create or delete)", action)
|
|
||||||
}
|
}
|
||||||
if _, dup := seen[action]; dup {
|
if _, dup := seen[action]; dup {
|
||||||
return fmt.Errorf("toolbar.buttons: duplicate action %s", action)
|
return fmt.Errorf("toolbar.buttons: duplicate action %s", action)
|
||||||
@@ -315,21 +312,43 @@ func (b *toolbarButtons) UnmarshalYAML(node ast.Node) error {
|
|||||||
b.items = items
|
b.items = items
|
||||||
return nil
|
return nil
|
||||||
default:
|
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 {
|
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...)
|
out := append([]string(nil), toolbar.Buttons.items...)
|
||||||
for _, action := range out {
|
custom := []ToolbarAction{}
|
||||||
if action == "delete" && !showCheckboxes {
|
for _, name := range out {
|
||||||
return nil, fmt.Errorf("toolbar.buttons: delete needs showCheckboxes: true")
|
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) {
|
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.SearchPrompt = translateKey(ctx, tr, s.SearchPrompt)
|
||||||
out.PerPageOptions = append([]int(nil), s.PerPageOptions...)
|
out.PerPageOptions = append([]int(nil), s.PerPageOptions...)
|
||||||
out.ToolbarButtons = append([]string(nil), s.ToolbarButtons...)
|
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 {
|
if s.DefaultSort != nil {
|
||||||
sort := *s.DefaultSort
|
sort := *s.DefaultSort
|
||||||
out.DefaultSort = &sort
|
out.DefaultSort = &sort
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ toolbar:
|
|||||||
prompt: backend::lang.list.search_prompt
|
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
|
// defaultListMessagesJSON is a compiled list's messages block when the YAML
|
||||||
// declares none: every key is a framework default phrase key (D-13).
|
// 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{
|
for _, want := range []string{
|
||||||
`"searchable":false`, `"sortable":false`, `"type":"datetime"`, `"type":"switch"`,
|
`"searchable":false`, `"sortable":false`, `"type":"datetime"`, `"type":"switch"`,
|
||||||
`"relation":"genre"`, `"select":"name"`, `"searchTerm":"search"`, `"showSetup":true`,
|
`"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) {
|
if !strings.Contains(got, want) {
|
||||||
t.Fatalf("compiled list missing %s:\n%s", want, got)
|
t.Fatalf("compiled list missing %s:\n%s", want, got)
|
||||||
@@ -102,7 +102,7 @@ modelClass: Widget
|
|||||||
recordsPerPage: 20
|
recordsPerPage: 20
|
||||||
`
|
`
|
||||||
got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns: {}\n")
|
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 {
|
if got != want {
|
||||||
t.Fatalf("empty list =\n%s\nwant\n%s", got, want)
|
t.Fatalf("empty list =\n%s\nwant\n%s", got, want)
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ modelClass: Widget
|
|||||||
recordsPerPage: 20
|
recordsPerPage: 20
|
||||||
`
|
`
|
||||||
got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns:\n name:\n label: Name\n searchable: true\n")
|
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 {
|
if got != want {
|
||||||
t.Fatalf("single list =\n%s\nwant\n%s", got, want)
|
t.Fatalf("single list =\n%s\nwant\n%s", got, want)
|
||||||
}
|
}
|
||||||
@@ -311,7 +311,7 @@ const filterColumns = `columns:
|
|||||||
searchable: true
|
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) {
|
func TestListSchemaFilter(t *testing.T) {
|
||||||
filters := readListFixture(t, "testdata/list/all_filters.yaml")
|
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)
|
return e.send(t, http.MethodPut, "/settings/conform", map[string]any{"enabled": true}, true)
|
||||||
}, into[cabana.Envelope[cabana.SettingsResult]]()},
|
}, into[cabana.Envelope[cabana.SettingsResult]]()},
|
||||||
{"GET /{vendor}/{plugin}/{controller}/schema/list", 200, "cabana.Envelope-cabana_ListSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
{"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]]()},
|
}, into[cabana.Envelope[cabana.ListSchema]]()},
|
||||||
{"GET /{vendor}/{plugin}/{controller}/schema/form", 200, "cabana.Envelope-cabana_FormView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
{"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)
|
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 {
|
{"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)
|
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]]()},
|
}, 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 {
|
{"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)
|
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)
|
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
|
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 {
|
func dataID(t *testing.T, raw []byte) uint {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var body struct {
|
var body struct {
|
||||||
@@ -423,8 +468,15 @@ func (c conformController) AdminActions() []pact.AdminAction {
|
|||||||
Run: func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) {
|
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
|
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 {
|
func conformFS() fs.FS {
|
||||||
file := func(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} }
|
file := func(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} }
|
||||||
@@ -437,9 +489,30 @@ recordsPerPage: 20
|
|||||||
showCheckboxes: true
|
showCheckboxes: true
|
||||||
filter: config_filter.yaml
|
filter: config_filter.yaml
|
||||||
toolbar:
|
toolbar:
|
||||||
buttons: [create, delete]
|
buttons: [create, delete, recount]
|
||||||
search:
|
search:
|
||||||
prompt: backend::lang.list.search_prompt
|
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:
|
"controllers/gadgets/config_filter.yaml": file(`scopes:
|
||||||
grouped:
|
grouped:
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ func TestPhase10Coverage(t *testing.T) {
|
|||||||
"POST /auth/refresh",
|
"POST /auth/refresh",
|
||||||
"POST /{vendor}/{plugin}/{controller}",
|
"POST /{vendor}/{plugin}/{controller}",
|
||||||
"POST /{vendor}/{plugin}/{controller}/bulk-delete",
|
"POST /{vendor}/{plugin}/{controller}/bulk-delete",
|
||||||
|
"POST /{vendor}/{plugin}/{controller}/toolbar/{action}",
|
||||||
"POST /{vendor}/{plugin}/{controller}/widgets/{field}",
|
"POST /{vendor}/{plugin}/{controller}/widgets/{field}",
|
||||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link",
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link",
|
||||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
|
||||||
@@ -101,7 +102,7 @@ func TestPhase10Coverage(t *testing.T) {
|
|||||||
"PUT /{vendor}/{plugin}/{controller}/{id}",
|
"PUT /{vendor}/{plugin}/{controller}/{id}",
|
||||||
}
|
}
|
||||||
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
|
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
|
// The routes added in Phase 10 are safe reads: GET /lang and the shared
|
||||||
// nested pattern serving field options, filter options and relation lists.
|
// 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,
|
// refresh, logout, settings put, create, bulk-delete, widget action,
|
||||||
// update, delete, link, unlink
|
// toolbar action, update, delete, link, unlink
|
||||||
if unsafe != 10 {
|
if unsafe != 11 {
|
||||||
t.Fatalf("walked %d state-changing routes, want 10: %v", unsafe, router.order)
|
t.Fatalf("walked %d state-changing routes, want 11: %v", unsafe, router.order)
|
||||||
}
|
}
|
||||||
|
|
||||||
loginHandler := router.handlers[login]
|
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) {
|
func compileRegistry(items []controllerRef) (*Registry, error) {
|
||||||
byID := make(map[string]*CompiledController, len(items))
|
byID := make(map[string]*CompiledController, len(items))
|
||||||
|
assets := map[string]*pluginAsset{}
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
id := item.ctl.ID()
|
id := item.ctl.ID()
|
||||||
if _, exists := byID[id]; exists {
|
if _, exists := byID[id]; exists {
|
||||||
return nil, fmt.Errorf("cabana: duplicate admin controller %s", id)
|
return nil, fmt.Errorf("cabana: duplicate admin controller %s", id)
|
||||||
}
|
}
|
||||||
assets, ok := item.plugin.(pact.AdminAssets)
|
fsys, ok := item.plugin.(pact.AdminAssets)
|
||||||
if !ok || assets == nil || assets.AdminFS() == nil {
|
if !ok || fsys == nil || fsys.AdminFS() == nil {
|
||||||
return nil, fmt.Errorf("cabana: plugin %s has admin controllers but no AdminFS", item.plugin.ID())
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -92,12 +93,18 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
|
|||||||
if err := BindWritableFields(compiled); err != nil {
|
if err := BindWritableFields(compiled); err != nil {
|
||||||
return nil, err
|
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
|
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
|
byID[id] = compiled
|
||||||
}
|
}
|
||||||
return &Registry{byID: byID}, nil
|
return &Registry{byID: byID, assets: assets}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func withoutAction(actions []string, drop string) []string {
|
func withoutAction(actions []string, drop string) []string {
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ type ListSchema struct {
|
|||||||
SearchPrompt string `json:"searchPrompt,omitempty"`
|
SearchPrompt string `json:"searchPrompt,omitempty"`
|
||||||
DefaultSort *ListSort `json:"defaultSort,omitempty"`
|
DefaultSort *ListSort `json:"defaultSort,omitempty"`
|
||||||
ToolbarButtons []string `json:"toolbarButtons"`
|
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"`
|
Columns []ListColumn `json:"columns"`
|
||||||
Filters []ListFilter `json:"filters"`
|
Filters []ListFilter `json:"filters"`
|
||||||
RowActions []RowAction `json:"rowActions"`
|
RowActions []RowAction `json:"rowActions"`
|
||||||
@@ -94,6 +99,9 @@ func (s ListSchema) MarshalJSON() ([]byte, error) {
|
|||||||
if out.ToolbarButtons == nil {
|
if out.ToolbarButtons == nil {
|
||||||
out.ToolbarButtons = []string{}
|
out.ToolbarButtons = []string{}
|
||||||
}
|
}
|
||||||
|
if out.ToolbarActions == nil {
|
||||||
|
out.ToolbarActions = []ToolbarAction{}
|
||||||
|
}
|
||||||
if out.Columns == nil {
|
if out.Columns == nil {
|
||||||
out.Columns = []ListColumn{}
|
out.Columns = []ListColumn{}
|
||||||
}
|
}
|
||||||
@@ -133,6 +141,36 @@ type FormView struct {
|
|||||||
// them onto its routes.
|
// them onto its routes.
|
||||||
Redirects FormRedirects `json:"redirects"`
|
Redirects FormRedirects `json:"redirects"`
|
||||||
Meta FormMeta `json:"meta"`
|
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.*.
|
// 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}"},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"},
|
{key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}/widgets/{field}"},
|
{key: "POST /{vendor}/{plugin}/{controller}/widgets/{field}"},
|
||||||
|
{key: "POST /{vendor}/{plugin}/{controller}/toolbar/{action}"},
|
||||||
{key: "GET /{vendor}/{plugin}/{controller}/{id}"},
|
{key: "GET /{vendor}/{plugin}/{controller}/{id}"},
|
||||||
{key: "PUT /{vendor}/{plugin}/{controller}/{id}"},
|
{key: "PUT /{vendor}/{plugin}/{controller}/{id}"},
|
||||||
{key: "DELETE /{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: "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}/link"},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"},
|
{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 ", public: true, spa: true},
|
||||||
{key: "GET /{path...}", public: true, spa: true},
|
{key: "GET /{path...}", public: true, spa: true},
|
||||||
}
|
}
|
||||||
@@ -266,6 +268,7 @@ func phase09ProtectedCalls() []phase09Call {
|
|||||||
{"create", (*service).create},
|
{"create", (*service).create},
|
||||||
{"bulk-delete", (*service).bulkDelete},
|
{"bulk-delete", (*service).bulkDelete},
|
||||||
{"widget-action", (*service).widgetAction},
|
{"widget-action", (*service).widgetAction},
|
||||||
|
{"toolbar-action", (*service).toolbarAction},
|
||||||
{"show", (*service).show},
|
{"show", (*service).show},
|
||||||
{"update", (*service).update},
|
{"update", (*service).update},
|
||||||
{"delete", (*service).deleteRecord},
|
{"delete", (*service).deleteRecord},
|
||||||
|
|||||||
Reference in New Issue
Block a user