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

@@ -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