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

@@ -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]]()},
@@ -475,7 +486,23 @@ func (c conformController) AdminActions() []pact.AdminAction {
},
}}
}
func (conformController) AdminJS() []string { return []string{"assets/js/lookup.js"} }
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: