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"];