feat(10.1-01): render header and form partials into an allowlisted node tree

- fields.yaml type: partial with a bare path name and config_list.yaml
  headerPartial resolve to {ConfigDir}/_{name}.htm, parsed at boot; the
  controller must implement pact.AdminPartialData
- html/template render against a curated view model, then x/net/html
  ParseFragment and a tag, attribute and URL allowlist with 64 KiB, 2000-node
  and depth-32 caps; the model type and trusted template types are refused
- GET .../partials/{name} with optional ?id= loaded through the form scope
- golang.org/x/net becomes a direct requirement (D-18), no new module
This commit is contained in:
Jakub Zych
2026-09-28 23:52:50 +02:00
parent 8b1cb244de
commit 771d2ccce0
16 changed files with 850 additions and 18 deletions

View File

@@ -365,6 +365,21 @@
],
"type": "object"
},
"cabana.Envelope-cabana_PartialView": {
"properties": {
"data": {
"$ref": "#/components/schemas/cabana.PartialView"
},
"meta": {
"$ref": "#/components/schemas/cabana.SuccessMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.Envelope-cabana_RelationMutationResult": {
"properties": {
"data": {
@@ -509,6 +524,10 @@
},
"type": "array"
},
"path": {
"description": "Path names the controller partial of a `type: partial` field: the\ntemplate {ConfigDir}/_{path}.htm (D-09).",
"type": "string"
},
"readOnly": {
"type": "boolean"
},
@@ -897,6 +916,10 @@
},
"type": "array"
},
"headerPartial": {
"description": "HeaderPartial names the controller partial rendered above the list\n(config_list.yaml headerPartial, D-11).",
"type": "string"
},
"messages": {
"allOf": [
{
@@ -1041,6 +1064,43 @@
],
"type": "object"
},
"cabana.PartialNode": {
"properties": {
"attrs": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"children": {
"items": {
"$ref": "#/components/schemas/cabana.PartialNode"
},
"type": "array"
},
"tag": {
"type": "string"
},
"text": {
"type": "string"
}
},
"type": "object"
},
"cabana.PartialView": {
"properties": {
"nodes": {
"items": {
"$ref": "#/components/schemas/cabana.PartialNode"
},
"type": "array"
}
},
"required": [
"nodes"
],
"type": "object"
},
"cabana.RecordEnvelope": {
"properties": {
"data": {
@@ -2531,6 +2591,108 @@
]
}
},
"/{vendor}/{plugin}/{controller}/partials/{name}": {
"get": {
"description": "Renders a declared header partial or form partial with html/template against the controller's view model and returns it as an allowlisted node tree: no HTML string. Without id the view model gets no record; id is accepted only for form partials and is loaded through the controller's form scope.",
"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": "Partial name",
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Record id for a form partial",
"in": "query",
"name": "id",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_PartialView"
}
}
},
"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"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Render a controller partial",
"tags": [
"admin"
]
}
},
"/{vendor}/{plugin}/{controller}/schema/form": {
"get": {
"parameters": [

View File

@@ -1025,6 +1025,84 @@ export interface paths {
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/partials/{name}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Render a controller partial
* @description Renders a declared header partial or form partial with html/template against the controller's view model and returns it as an allowlisted node tree: no HTML string. Without id the view model gets no record; id is accepted only for form partials and is loaded through the controller's form scope.
*/
get: {
parameters: {
query?: {
/** @description Record id for a form partial */
id?: number;
};
header?: never;
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Partial name */
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_PartialView"];
};
};
/** @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"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/schema/form": {
parameters: {
query?: never;
@@ -2090,6 +2168,10 @@ export interface components {
data: components["schemas"]["cabana.ListSchema"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_PartialView": {
data: components["schemas"]["cabana.PartialView"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_RelationMutationResult": {
data: components["schemas"]["cabana.RelationMutationResult"];
meta: components["schemas"]["cabana.SuccessMeta"];
@@ -2135,6 +2217,11 @@ export interface components {
name: string;
nameFrom?: string;
options?: components["schemas"]["cabana.FormOption"][];
/**
* @description Path names the controller partial of a `type: partial` field: the
* template {ConfigDir}/_{path}.htm (D-09).
*/
path?: string;
readOnly?: boolean;
relation?: string;
required?: boolean;
@@ -2245,6 +2332,11 @@ export interface components {
columns: components["schemas"]["cabana.ListColumn"][];
defaultSort?: components["schemas"]["cabana.ListSort"];
filters: components["schemas"]["cabana.ListFilter"][];
/**
* @description HeaderPartial names the controller partial rendered above the list
* (config_list.yaml headerPartial, D-11).
*/
headerPartial?: string;
/**
* @description 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.
@@ -2286,6 +2378,17 @@ export interface components {
order: number;
sideMenu: components["schemas"]["cabana.NavigationEntry"][];
};
"cabana.PartialNode": {
attrs?: {
[key: string]: string;
};
children?: components["schemas"]["cabana.PartialNode"][];
tag?: string;
text?: string;
};
"cabana.PartialView": {
nodes: components["schemas"]["cabana.PartialNode"][];
};
"cabana.RecordEnvelope": {
data: components["schemas"]["cabana.AdminRecord"];
meta: components["schemas"]["cabana.RecordMeta"];

2
go.mod
View File

@@ -26,6 +26,7 @@ require (
github.com/yuin/goldmark v1.8.6
gocloud.dev v0.46.0
golang.org/x/crypto v0.57.0
golang.org/x/net v0.58.0
golang.org/x/term v0.46.0
golang.org/x/text v0.42.0
gorm.io/driver/postgres v1.6.3
@@ -101,7 +102,6 @@ require (
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect

View File

@@ -17,6 +17,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- 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.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` 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.
@@ -41,12 +42,31 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
| 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 `/{vendor}/{plugin}/{controller}/partials/{name}` | Render a declared header or form partial as a node tree; `?id=` (form partials only) passes the scoped record to the view model. |
| 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.
### Partials
A partial is an `html/template` file next to the controller's YAML: `headerPartial: stats` and `path: stats` both resolve to `{ConfigDir}/_stats.htm`; Winter's `$/` and `~/` paths are not supported. The template's root is `.Data`, the value the controller's `PartialData(ctx, name, record)` returns, and `trans "<key>"` translates a phrase key in the request locale. `record` is nil for a header partial and for a form partial on the create form; with `?id=` it is the record cabana loaded through the controller's `pact.FormExtendQuery` scope, so a plugin never looks a record up by a request id itself.
The view model must be a curated struct built for the template. cabana refuses a value of the controller's own model type (or a pointer to or a collection of it) and a type that carries `html/template`'s pre-escaped content types, so escaping stays on for every record value. The rendered output is parsed with `golang.org/x/net/html` and walked through an allowlist:
- Elements: `div span p strong em b i u s small mark code pre br hr ul ol li dl dt dd h2 h3 h4 h5 h6 table thead tbody tfoot tr th td caption section header footer figure figcaption blockquote q abbr time data meter progress sup sub a img`. Any other element is unwrapped (its children stay); `script style template iframe object embed noscript textarea title xmp svg math form input button select link meta base` are removed with everything inside them, and comments disappear.
- Attributes: `class title lang dir role`, `aria-*` and `data-*` everywhere; `a[href]` and `img[src]` only for a same-origin path starting with exactly one `/` (links may also use `#fragment`); `img[alt width height]`, `td`/`th[colspan rowspan scope]`, `time[datetime]`, `data[value]`, `meter[value min max low high optimum]`, `progress[value max]`. `id`, `style` and every event handler are dropped.
- Caps: 64 KiB of template output, 2000 nodes and a depth of 32. Exceeding one, a view model error or a refused view model is logged with the controller and partial name and answered with the generic 500 body, never a truncated tree.
A statistics strip above a list, for example:
```html
<dl class="summer-stats">
<div class="summer-stat"><dt class="summer-stat__label">{{ trans "acme.blog::lang.stats.posts" }}</dt><dd class="summer-stat__value">{{ .Data.Total }}</dd></div>
</dl>
```
### 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.
@@ -123,6 +143,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `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. |
| `cabana.PartialView` / `cabana.PartialNode` | A rendered partial: a list of nodes, each an allowlisted element (`tag`, `attrs`, `children`) or a text node (`text`). |
## Configuration
@@ -158,8 +179,8 @@ Both commands are added to every application binary by the generated `main` and
## Dependencies
- 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`, `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`.
- Third-party: `gorm.io/gorm` (with `gorm.io/gorm/clause`), `github.com/goccy/go-yaml` (with its `ast` package), `golang.org/x/net/html` (with its `atom` package; parses rendered partials for the allowlist walk).
- Standard library: `bytes`, `context`, `crypto/sha256`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `html/template`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `path`, `reflect`, `regexp`, `sort`, `strconv`, `strings`, `time`.
- Tests additionally use `github.com/testcontainers/testcontainers-go` and its `modules/postgres` package.
## Testing

View File

@@ -446,6 +446,25 @@ func AdminWidgetAction() {}
// @Router /{vendor}/{plugin}/{controller}/toolbar/{action} [post]
func AdminToolbarAction() {}
// AdminPartial documents the controller partial route.
//
// @Summary Render a controller partial
// @Description Renders a declared header partial or form partial with html/template against the controller's view model and returns it as an allowlisted node tree: no HTML string. Without id the view model gets no record; id is accepted only for form partials and is loaded through the controller's form scope.
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param name path string true "Partial name"
// @Param id query integer false "Record id for a form partial"
// @Success 200 {object} Envelope[PartialView]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/partials/{name} [get]
func AdminPartial() {}
// AdminShow documents the record show route.
//
// @Summary Show an admin record

View File

@@ -82,6 +82,11 @@ type CompiledController struct {
// in declared order.
scripts []*pluginAsset
styles []*pluginAsset
// partials are the parsed controller partials (headerPartial and every
// `type: partial` path) keyed by name; formPartials are the names form
// fields declare, the only ones a request may render with a record id.
partials map[string]*compiledPartial
formPartials map[string]bool
}
// Registry is the immutable controller map keyed by controller ID.

View File

@@ -70,6 +70,9 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
if err := compileClientAssets(pluginID, cc, fsys); err != nil {
return err
}
if err := compilePartials(pluginID, cc, fsys); err != nil {
return err
}
if cc.Form == nil {
return nil
}
@@ -115,6 +118,55 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
return nil
}
// compilePartials reads and parses every partial the controller declares:
// config_list.yaml headerPartial and each `type: partial` field's path, both
// resolving to {ConfigDir}/_{name}.htm. A missing or unparsable template, or a
// controller without pact.AdminPartialData, fails boot (D-11).
func compilePartials(pluginID string, cc *CompiledController, fsys fs.FS) error {
id := cc.Controller.ID()
var names []string
formNames := map[string]bool{}
if cc.List != nil && cc.List.HeaderPartial != "" {
names = append(names, cc.List.HeaderPartial)
}
if cc.Form != nil {
for _, field := range cc.Form.Fields {
if field.Type == "partial" {
names = append(names, field.Path)
formNames[field.Path] = true
}
}
}
if len(names) == 0 {
return nil
}
dir := strings.Trim(path.Clean(cc.Controller.ConfigDir()), "/")
if dir == "." || strings.HasPrefix(dir, "..") {
return bootErr(pluginID, id, cc.Controller.ConfigDir(), fmt.Errorf("config directory escapes the plugin"))
}
partials := map[string]*compiledPartial{}
for _, name := range names {
if _, done := partials[name]; done {
continue
}
file := path.Join(dir, "_"+name+".htm")
if provider, ok := cc.Controller.(pact.AdminPartialData); !ok || provider == nil {
return bootErr(pluginID, id, file, fmt.Errorf("partial %s needs the controller to implement pact.AdminPartialData", name))
}
src, err := readAsset(fsys, file)
if err != nil {
return bootErr(pluginID, id, file, fmt.Errorf("partial %s: template is not in the plugin's embedded files: %w", name, err))
}
compiled, err := parsePartial(name, src)
if err != nil {
return bootErr(pluginID, id, file, fmt.Errorf("partial %s: %w", name, err))
}
partials[name] = compiled
}
cc.partials, cc.formPartials = partials, formNames
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

View File

@@ -23,7 +23,7 @@ var (
formFieldTypes = map[string]struct{}{
"text": {}, "textarea": {}, "number": {}, "checkbox": {},
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
"widget": {},
"widget": {}, "partial": {},
}
formSpans = map[string]struct{}{
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
@@ -35,7 +35,7 @@ var (
"label": {}, "comment": {}, "span": {}, "type": {}, "required": {},
"tab": {}, "context": {}, "attributes": {}, "size": {}, "default": {},
"nameFrom": {}, "emptyOption": {}, "options": {}, "relation": {},
"widget": {}, "action": {}, "fill": {},
"widget": {}, "action": {}, "fill": {}, "path": {},
}
// widgetKeys are valid only on `type: widget` (D-06).
widgetKeys = []string{"widget", "action", "fill"}
@@ -348,9 +348,6 @@ func compileFieldNode(name string, node ast.Node) (FormField, error) {
if err != nil || typ == "" {
return FormField{}, fmt.Errorf("type is required")
}
if typ == "partial" {
return FormField{}, fmt.Errorf("type partial is not supported")
}
if _, ok := formFieldTypes[typ]; !ok {
return FormField{}, fmt.Errorf("unsupported type %s", typ)
}
@@ -422,6 +419,9 @@ func compileFieldNode(name string, node ast.Node) (FormField, error) {
if err := compileWidgetKeys(typ, values, &field); err != nil {
return FormField{}, err
}
if err := compilePartialPath(typ, values, &field); err != nil {
return FormField{}, err
}
if node, ok := values["required"]; ok {
field.Required, err = nodeBool(node)
if err != nil {
@@ -498,6 +498,32 @@ func compileWidgetKeys(typ string, values map[string]ast.Node, field *FormField)
return nil
}
// partialPathHint is the D-11 path rule shared by every partial path error.
const partialPathHint = "path must be a partial name such as summary (resolves to CONFIG_DIR/_summary.htm); Winter $/ and ~/ paths are not supported"
// compilePartialPath decodes the path of a `type: partial` field (D-09). It
// is refused on any other type and must be a bare partial name, so no
// free-form path ever reaches the plugin's file tree. The template itself is
// read and parsed in compileExtension.
func compilePartialPath(typ string, values map[string]ast.Node, field *FormField) error {
node, ok := values["path"]
if typ != "partial" {
if ok {
return fmt.Errorf("path is only valid on type: partial")
}
return nil
}
if !ok {
return fmt.Errorf("type partial needs a path: %s", partialPathHint)
}
name, err := nodeString(node)
if err != nil || !identifier(name) {
return fmt.Errorf("partial %q: %s", nodeText(node), partialPathHint)
}
field.Path = name
return nil
}
func compileContext(node ast.Node) (*fieldContext, error) {
switch n := node.(type) {
case *ast.StringNode:

View File

@@ -224,13 +224,15 @@ func TestFormSchemaRejects(t *testing.T) {
name: "partial",
config: formConfig,
fields: "fields:\n editors:\n type: partial\n tab: Editors\n span: full\n",
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "partial"},
// A partial now needs a path naming its template.
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "partial", "path"},
},
{
name: "partial path",
config: formConfig,
fields: "fields:\n editors:\n type: partial\n path: $/golem15/acme/controllers/collections/_editors.htm\n",
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "path"},
// Winter's $/ and ~/ paths are refused: only a bare partial name.
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "partial", "path", "not supported"},
},
{
name: "missing fields",
@@ -277,7 +279,7 @@ func TestFormSchemaRejects(t *testing.T) {
plugin: &formPlugin{fsys: fsys},
ctl: schemaController{model: "Widget"},
}})
if err == nil || !strings.Contains(err.Error(), "partial") {
if err == nil || !strings.Contains(err.Error(), "partial") || !strings.Contains(err.Error(), "path") {
t.Fatalf("activation err = %v", err)
}
})

View File

@@ -226,6 +226,8 @@ func (s *service) mount(r pact.Router) {
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}/partials/{name}", s.partial)
constrainRelation(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}", s.show)
constrainController(g)
g.Put("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.update))

View File

@@ -39,6 +39,7 @@ type listDocument struct {
Toolbar *listToolbar `yaml:"toolbar"`
Filter string `yaml:"filter"`
Messages *listMessageKeys `yaml:"messages"`
HeaderPartial string `yaml:"headerPartial"`
}
type listSortDocument struct {
@@ -137,6 +138,9 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
if doc.ShowSorting != nil {
showSorting = *doc.ShowSorting
}
if doc.HeaderPartial != "" && !identifier(doc.HeaderPartial) {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("headerPartial %q: %s", doc.HeaderPartial, partialPathHint))
}
rowActions := []RowAction{}
if doc.RecordURL != "" {
rowActions = append(rowActions, RowAction{
@@ -172,6 +176,7 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
DefaultSort: sort,
ToolbarButtons: buttons,
ToolbarActions: toolbarActions,
HeaderPartial: doc.HeaderPartial,
Columns: columns,
Filters: filters,
RowActions: rowActions,

View File

@@ -130,6 +130,17 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
}
return rec
}, into[cabana.Envelope[cabana.AdminActionResult]]()},
{"GET /{vendor}/{plugin}/{controller}/partials/{name}", 200, "cabana.Envelope-cabana_PartialView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/partials/summary?id=%d", e.gadgetID), nil, true)
if !strings.Contains(rec.Body.String(), `"text":"gadget-`+e.stamp+`"`) {
t.Fatalf("form partial did not render the scoped record: %s", rec.Body.String())
}
header := e.send(t, http.MethodGet, "/acme/conform/gadgets/partials/stats", nil, true)
if header.Code != http.StatusOK || !strings.Contains(header.Body.String(), `"class":"summer-stats"`) {
t.Fatalf("header partial status=%d body=%s", header.Code, header.Body.String())
}
return rec
}, into[cabana.Envelope[cabana.PartialView]]()},
{"GET /{vendor}/{plugin}/{controller}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets?search="+e.stamp, nil, true)
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()},
@@ -476,6 +487,22 @@ func (c conformController) AdminActions() []pact.AdminAction {
}}
}
func (conformController) AdminJS() []string { return []string{"assets/js/lookup.js"} }
// PartialData supplies curated view models, never the gadget model itself.
func (conformController) PartialData(_ context.Context, name string, record any) (any, error) {
switch name {
case "stats":
return struct{ Total int }{Total: 1}, nil
case "summary":
view := struct{ Name string }{}
if gadget, ok := record.(*conformGadget); ok && gadget != nil {
view.Name = gadget.Name
}
return view, nil
default:
return nil, fmt.Errorf("unknown partial %s", name)
}
}
func (conformController) AdminCSS() []string { return []string{"assets/css/gadgets.css"} }
func conformFS() fs.FS {
@@ -488,6 +515,7 @@ recordUrl: acme/conform/gadgets/update/:id
recordsPerPage: 20
showCheckboxes: true
filter: config_filter.yaml
headerPartial: stats
toolbar:
buttons: [create, delete, recount]
search:
@@ -513,6 +541,12 @@ if (!customElements.get('acme-conform-lookup')) {
}
`),
"assets/css/gadgets.css": file(`acme-conform-lookup button { font: inherit; }
`),
"controllers/gadgets/_stats.htm": file(`<dl class="summer-stats">
<div class="summer-stat"><dt class="summer-stat__label">{{ trans "backend::lang.list.search" }}</dt><dd class="summer-stat__value">{{ .Data.Total }}</dd></div>
</dl>
`),
"controllers/gadgets/_summary.htm": file(`<p>{{ .Data.Name }}</p>
`),
"controllers/gadgets/config_filter.yaml": file(`scopes:
grouped:
@@ -585,6 +619,10 @@ update:
widget: acme-conform-lookup
action: lookup
fill: [name]
summary:
label: Summary
type: partial
path: summary
`),
"models/settings/fields.yaml": file(`fields:
enabled:

View File

@@ -0,0 +1,374 @@
package cabana
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"log/slog"
"net/http"
"reflect"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// Partial render caps (T-10.1-12). Exceeding any of them is an error, never a
// truncated tree.
const (
partialMaxBytes = 64 << 10
partialMaxNodes = 2000
partialMaxDepth = 32
)
// compiledPartial is one controller partial template, parsed at boot. The
// pristine template is never executed: html/template refuses to Clone a
// template after Execute, so every render executes a clone.
type compiledPartial struct {
name string
pristine *template.Template
}
// partialFuncs are the functions a partial template may call. trans is bound
// per request; the boot parse only needs the name to exist.
func partialFuncs(trans func(string) string) template.FuncMap {
return template.FuncMap{"trans": trans}
}
// parsePartial parses a partial template with html/template, so contextual
// escaping stays on for every value the view model supplies (D-10).
func parsePartial(name string, src []byte) (*compiledPartial, error) {
tpl, err := template.New(name).Funcs(partialFuncs(func(key string) string { return key })).Parse(string(src))
if err != nil {
return nil, err
}
return &compiledPartial{name: name, pristine: tpl}, nil
}
// render executes the partial against the curated view model and returns the
// allowlisted node tree (D-10, D-17). The template's root is {"Data": data}.
func (p *compiledPartial) render(ctx context.Context, tr *phrasebook.Translator, data any) ([]PartialNode, error) {
tpl, err := p.pristine.Clone()
if err != nil {
return nil, err
}
tpl.Funcs(partialFuncs(func(key string) string { return translateKey(ctx, tr, key) }))
out := &cappedBuffer{limit: partialMaxBytes}
if err := tpl.Execute(out, map[string]any{"Data": data}); err != nil {
return nil, err
}
container := &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div}
parsed, err := html.ParseFragment(bytes.NewReader(out.buf.Bytes()), container)
if err != nil {
return nil, err
}
budget := &partialBudget{nodes: partialMaxNodes}
return sanitizePartialNodes(parsed, 0, budget)
}
var errPartialTooLarge = errors.New("cabana: partial output exceeds the size cap")
// cappedBuffer fails a write that would grow past limit bytes.
type cappedBuffer struct {
buf bytes.Buffer
limit int
}
func (b *cappedBuffer) Write(p []byte) (int, error) {
if b.buf.Len()+len(p) > b.limit {
return 0, errPartialTooLarge
}
return b.buf.Write(p)
}
type partialBudget struct {
nodes int
}
func (b *partialBudget) take() error {
b.nodes--
if b.nodes < 0 {
return fmt.Errorf("cabana: partial output exceeds %d nodes", partialMaxNodes)
}
return nil
}
// partialTags are the elements a partial may emit (RESEARCH Pattern 4; the
// SPA mirrors the same list).
var partialTags = setOf("div", "span", "p", "strong", "em", "b", "i", "u", "s", "small", "mark",
"code", "pre", "br", "hr", "ul", "ol", "li", "dl", "dt", "dd", "h2", "h3", "h4", "h5", "h6",
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "section", "header", "footer",
"figure", "figcaption", "blockquote", "q", "abbr", "time", "data", "meter", "progress", "sup",
"sub", "a", "img")
// partialDroppedTags are removed together with everything inside them.
var partialDroppedTags = setOf("script", "style", "template", "iframe", "object", "embed",
"noscript", "textarea", "title", "xmp", "svg", "math", "form", "input", "button", "select",
"link", "meta", "base")
// partialGlobalAttrs are allowed on every allowlisted element, next to aria-*
// and data-*.
var partialGlobalAttrs = setOf("class", "title", "lang", "dir", "role")
// partialTagAttrs are the per-element attributes. a[href] and img[src] also
// pass safePartialURL.
var partialTagAttrs = map[string]map[string]bool{
"a": setOf("href"),
"img": setOf("src", "alt", "width", "height"),
"td": setOf("colspan", "rowspan", "scope"),
"th": setOf("colspan", "rowspan", "scope"),
"time": setOf("datetime"),
"data": setOf("value"),
"meter": setOf("value", "min", "max", "low", "high", "optimum"),
"progress": setOf("value", "max"),
}
func setOf(items ...string) map[string]bool {
out := make(map[string]bool, len(items))
for _, item := range items {
out[item] = true
}
return out
}
// sanitizePartialNodes walks parsed nodes through the allowlist. Allowed
// elements keep their allowlisted attributes, dropped elements lose their
// whole subtree, any other element is unwrapped (its children kept), and
// comments and doctypes disappear. The result is never nil.
func sanitizePartialNodes(nodes []*html.Node, depth int, budget *partialBudget) ([]PartialNode, error) {
out := []PartialNode{}
for _, node := range nodes {
converted, err := sanitizePartialNode(node, depth, budget)
if err != nil {
return nil, err
}
out = append(out, converted...)
}
return out, nil
}
func sanitizePartialNode(node *html.Node, depth int, budget *partialBudget) ([]PartialNode, error) {
switch node.Type {
case html.TextNode:
if node.Data == "" {
return nil, nil
}
if err := budget.take(); err != nil {
return nil, err
}
return []PartialNode{{Text: node.Data}}, nil
case html.ElementNode:
tag := strings.ToLower(node.Data)
// Foreign (SVG, MathML) content and dropped elements go with their subtree.
if node.Namespace != "" || partialDroppedTags[tag] {
return nil, nil
}
if !partialTags[tag] {
return sanitizePartialNodes(childNodes(node), depth, budget)
}
if depth+1 > partialMaxDepth {
return nil, fmt.Errorf("cabana: partial output exceeds depth %d", partialMaxDepth)
}
if err := budget.take(); err != nil {
return nil, err
}
children, err := sanitizePartialNodes(childNodes(node), depth+1, budget)
if err != nil {
return nil, err
}
out := PartialNode{Tag: tag, Attrs: sanitizePartialAttrs(tag, node.Attr)}
if len(children) > 0 {
out.Children = children
}
return []PartialNode{out}, nil
default:
return nil, nil
}
}
func childNodes(node *html.Node) []*html.Node {
var out []*html.Node
for child := node.FirstChild; child != nil; child = child.NextSibling {
out = append(out, child)
}
return out
}
func sanitizePartialAttrs(tag string, attrs []html.Attribute) map[string]string {
var out map[string]string
for _, attr := range attrs {
if attr.Namespace != "" {
continue
}
key := strings.ToLower(attr.Key)
allowed := partialGlobalAttrs[key] || partialTagAttrs[tag][key] ||
(strings.HasPrefix(key, "aria-") && len(key) > len("aria-")) ||
(strings.HasPrefix(key, "data-") && len(key) > len("data-"))
if !allowed {
continue
}
if (tag == "a" && key == "href") || (tag == "img" && key == "src") {
if !safePartialURL(attr.Val, tag == "a") {
continue
}
}
if out == nil {
out = map[string]string{}
}
out[key] = attr.Val
}
return out
}
// safePartialURL accepts a same-origin path that starts with exactly one
// slash (never "//" or "/\", which browsers resolve to another host) and, for
// links, a fragment. Whitespace and control characters, which browsers strip
// before resolving, are refused outright, and so is every scheme.
func safePartialURL(raw string, allowFragment bool) bool {
if raw == "" {
return false
}
for _, r := range raw {
if r <= 0x20 || r == 0x7f {
return false
}
}
if raw[0] == '#' {
return allowFragment
}
if raw[0] != '/' {
return false
}
return len(raw) == 1 || (raw[1] != '/' && raw[1] != '\\')
}
// trustedTemplateTypes are html/template's pre-escaped content types. A view
// model carrying them could smuggle markup past autoescaping, so they are
// refused like the model itself.
var trustedTemplateTypes = map[reflect.Type]bool{
reflect.TypeOf(template.HTML("")): true,
reflect.TypeOf(template.HTMLAttr("")): true,
reflect.TypeOf(template.JS("")): true,
reflect.TypeOf(template.JSStr("")): true,
reflect.TypeOf(template.CSS("")): true,
reflect.TypeOf(template.URL("")): true,
reflect.TypeOf(template.Srcset("")): true,
}
// refusedViewModel reports why a view model may not reach a template (D-10):
// it is (a pointer to, or a collection of) the controller's own model type,
// or its type contains one of html/template's trusted content types.
func refusedViewModel(cc *CompiledController, vm any) string {
if vm == nil {
return ""
}
if src, ok := cc.Controller.(pact.AdminRecordSource); ok && src != nil {
if model := src.NewRecord(); model != nil && baseType(reflect.TypeOf(vm)) == baseType(reflect.TypeOf(model)) {
return "the view model is the controller's model"
}
}
if carriesTrustedContent(reflect.TypeOf(vm), map[reflect.Type]bool{}) {
return "the view model carries pre-escaped html/template content"
}
return ""
}
// baseType strips pointers and the element types of slices, arrays and maps.
func baseType(t reflect.Type) reflect.Type {
for t != nil {
switch t.Kind() {
case reflect.Pointer, reflect.Slice, reflect.Array, reflect.Map:
t = t.Elem()
default:
return t
}
}
return t
}
func carriesTrustedContent(t reflect.Type, seen map[reflect.Type]bool) bool {
if t == nil || seen[t] {
return false
}
seen[t] = true
if trustedTemplateTypes[t] {
return true
}
switch t.Kind() {
case reflect.Pointer, reflect.Slice, reflect.Array:
return carriesTrustedContent(t.Elem(), seen)
case reflect.Map:
return carriesTrustedContent(t.Key(), seen) || carriesTrustedContent(t.Elem(), seen)
case reflect.Struct:
for i := 0; i < t.NumField(); i++ {
if carriesTrustedContent(t.Field(i).Type, seen) {
return true
}
}
}
return false
}
// partial serves GET .../{controller}/partials/{name} (D-09, D-10, D-11,
// D-17). Without ?id= the view model gets a nil record (a header partial, or
// a form partial on the create form). With ?id= the name must belong to a
// form partial field and the record is loaded through the controller's form
// scope; out of scope is 404. Every render failure, including a cap, is a
// logged 500 with the generic body.
func (s *service) partial(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
name := r.PathValue("name")
compiled, ok := cc.partials[name]
provider, isProvider := cc.Controller.(pact.AdminPartialData)
if !ok || !isProvider || provider == nil {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
var record any
query := r.URL.Query()
if query.Has("id") {
id, err := strconv.ParseUint(query.Get("id"), 10, 64)
if err != nil || id == 0 || !cc.formPartials[name] {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
record, err = readScopedRecord(r.Context(), db, cc, id)
if err != nil {
writeCRUDError(w, err)
return
}
}
tr := s.translator()
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), tr))
fail := func(reason string, err error) {
slog.Error("cabana: admin partial failed", "controller", controllerID(cc), "partial", name, "reason", reason, "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
}
vm, err := provider.PartialData(ctx, name, record)
if err != nil {
fail("view model", err)
return
}
if reason := refusedViewModel(cc, vm); reason != "" {
fail(reason, nil)
return
}
nodes, err := compiled.render(ctx, tr, vm)
if err != nil {
fail("render", err)
return
}
WriteData(w, http.StatusOK, PartialView{Nodes: nodes}, nil)
})
}

View File

@@ -73,6 +73,9 @@ type ListSchema struct {
ToolbarActions []ToolbarAction `json:"toolbarActions"`
// Assets are the controller's plugin JS and CSS URLs (pact.AdminClientAssets).
Assets ControllerAssets `json:"assets"`
// HeaderPartial names the controller partial rendered above the list
// (config_list.yaml headerPartial, D-11).
HeaderPartial string `json:"headerPartial,omitempty"`
Columns []ListColumn `json:"columns"`
Filters []ListFilter `json:"filters"`
RowActions []RowAction `json:"rowActions"`
@@ -218,6 +221,9 @@ type FormField struct {
ActionLabel string `json:"actionLabel,omitempty"`
// Fill lists the fields of the same form the action writes back (D-07).
Fill []string `json:"fill,omitempty"`
// Path names the controller partial of a `type: partial` field: the
// template {ConfigDir}/_{path}.htm (D-09).
Path string `json:"path,omitempty"`
optionsMethod string
}
@@ -293,3 +299,18 @@ func (c fieldContext) MarshalJSON() ([]byte, error) {
}
return json.Marshal(values)
}
// PartialNode is one node of a rendered controller partial (D-17): an element
// with its allowlisted tag, attributes and children, or a text node. The SPA
// builds DOM from these nodes; no HTML string leaves the server.
type PartialNode struct {
Tag string `json:"tag,omitempty"`
Attrs map[string]string `json:"attrs,omitempty"`
Text string `json:"text,omitempty"`
Children []PartialNode `json:"children,omitempty"`
}
// PartialView is a rendered controller partial. Nodes is always an array.
type PartialView struct {
Nodes []PartialNode `json:"nodes"`
}

View File

@@ -45,6 +45,7 @@ var phase09Routes = []adminRoute{
{key: "GET /{vendor}/{plugin}/{controller}/schema/list"},
{key: "GET /{vendor}/{plugin}/{controller}/schema/form"},
{key: "GET /{vendor}/{plugin}/{controller}/schema/relation/{name}"},
{key: "GET /{vendor}/{plugin}/{controller}/partials/{name}"},
{key: "GET /{vendor}/{plugin}/{controller}/fields/{field}/options", mounted: nestedGetRoute},
{key: "GET /{vendor}/{plugin}/{controller}/filters/{scope}/options", mounted: nestedGetRoute},
{key: "GET /{vendor}/{plugin}/{controller}"},
@@ -269,6 +270,7 @@ func phase09ProtectedCalls() []phase09Call {
{"bulk-delete", (*service).bulkDelete},
{"widget-action", (*service).widgetAction},
{"toolbar-action", (*service).toolbarAction},
{"partial", (*service).partial},
{"show", (*service).show},
{"update", (*service).update},
{"delete", (*service).deleteRecord},

View File

@@ -63,7 +63,7 @@ func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*Compi
}
for _, field := range fields {
// A settings screen has no admin controller to own actions or view models.
if field.Type == "widget" {
if field.Type == "widget" || field.Type == "partial" {
return nil, fmt.Errorf("cabana: setting %s field %s: type %s is not supported on a settings form", item.Code, field.Name, field.Type)
}
}