fix(10.1): WR-03 walk the whole partial view model before rendering
refusedViewModel compared only the top-level type with the controller's model. It now walks the type through pointers, slices, arrays, maps, struct fields and the results of exported methods, and the values held in interface-typed members, refusing the controller's model, any other GORM model (TableName, a gorm tag, gorm.Model, gorm.DeletedAt) and html/template's trusted content types anywhere in that structure.
This commit is contained in:
@@ -53,7 +53,7 @@ Every path under the prefix that no API route matches is served by the admin SPA
|
|||||||
|
|
||||||
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.
|
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:
|
The view model must be a curated struct built for the template. cabana walks its type through pointers, slices, arrays, maps, struct fields and the results of its exported methods (templates call methods), and the values held in interface-typed members such as `map[string]any`. It refuses the controller's own model type, any other GORM model (a struct with a `TableName` method, a `gorm` struct tag, `gorm.Model` or `gorm.DeletedAt`) and `html/template`'s pre-escaped content types anywhere in that structure, so escaping stays on for every record value. A method that returns an interface is not called, so its run-time result is not checked. 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/modules/towel"
|
"git.golem15.com/golem15/summercms/modules/towel"
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
"golang.org/x/net/html/atom"
|
"golang.org/x/net/html/atom"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Partial render caps (T-10.1-12). Exceeding any of them is an error, never a
|
// Partial render caps (T-10.1-12). Exceeding any of them is an error, never a
|
||||||
@@ -261,24 +262,222 @@ var trustedTemplateTypes = map[reflect.Type]bool{
|
|||||||
reflect.TypeOf(template.Srcset("")): true,
|
reflect.TypeOf(template.Srcset("")): true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// refusedViewModel reports why a view model may not reach a template (D-10):
|
// viewModelBudget bounds how many values refusedViewModel inspects while it
|
||||||
// it is (a pointer to, or a collection of) the controller's own model type,
|
// resolves interface-typed members. A curated view model is small; one that
|
||||||
// or its type contains one of html/template's trusted content types.
|
// is not is refused rather than walked without limit.
|
||||||
|
const viewModelBudget = 10000
|
||||||
|
|
||||||
|
var (
|
||||||
|
tablerType = reflect.TypeOf((*interface{ TableName() string })(nil)).Elem()
|
||||||
|
gormModelType = reflect.TypeOf(gorm.Model{})
|
||||||
|
gormDeletedAtTyp = reflect.TypeOf(gorm.DeletedAt{})
|
||||||
|
)
|
||||||
|
|
||||||
|
// refusedViewModel reports why a view model may not reach a template (D-10,
|
||||||
|
// T-10.1-09). It walks the view model's type through pointers, slices,
|
||||||
|
// arrays, maps, struct fields and the results of its methods (html/template
|
||||||
|
// calls methods, and emits a trusted result unescaped), and the values held
|
||||||
|
// in interface-typed members such as map[string]any. It refuses:
|
||||||
|
// - the controller's own model type anywhere in that structure;
|
||||||
|
// - any other GORM model: a struct with a TableName method, a gorm struct
|
||||||
|
// tag, gorm.Model or gorm.DeletedAt;
|
||||||
|
// - html/template's trusted content types.
|
||||||
|
//
|
||||||
|
// A method that returns an interface is not called, so what it returns at
|
||||||
|
// run time is not checked.
|
||||||
func refusedViewModel(cc *CompiledController, vm any) string {
|
func refusedViewModel(cc *CompiledController, vm any) string {
|
||||||
if vm == nil {
|
if vm == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
g := &viewModelGuard{types: map[reflect.Type]string{}, holds: map[reflect.Type]bool{}, visited: map[viewModelVisit]bool{}, budget: viewModelBudget}
|
||||||
if src, ok := cc.Controller.(pact.AdminRecordSource); ok && src != nil {
|
if src, ok := cc.Controller.(pact.AdminRecordSource); ok && src != nil {
|
||||||
if model := src.NewRecord(); model != nil && baseType(reflect.TypeOf(vm)) == baseType(reflect.TypeOf(model)) {
|
if model := src.NewRecord(); model != nil {
|
||||||
return "the view model is the controller's model"
|
g.model = baseType(reflect.TypeOf(model))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return g.value(reflect.ValueOf(vm))
|
||||||
|
}
|
||||||
|
|
||||||
|
type viewModelVisit struct {
|
||||||
|
ptr uintptr
|
||||||
|
typ reflect.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
type viewModelGuard struct {
|
||||||
|
model reflect.Type
|
||||||
|
types map[reflect.Type]string
|
||||||
|
holds map[reflect.Type]bool
|
||||||
|
visited map[viewModelVisit]bool
|
||||||
|
budget int
|
||||||
|
}
|
||||||
|
|
||||||
|
// value checks a value's static type, then descends into the values only
|
||||||
|
// where the type holds an interface, whose dynamic content the type cannot
|
||||||
|
// show.
|
||||||
|
func (g *viewModelGuard) value(v reflect.Value) string {
|
||||||
|
if !v.IsValid() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
g.budget--
|
||||||
|
if g.budget < 0 {
|
||||||
|
return "the view model is too large to inspect"
|
||||||
|
}
|
||||||
|
if v.Kind() == reflect.Interface {
|
||||||
|
if v.IsNil() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return g.value(v.Elem())
|
||||||
|
}
|
||||||
|
if reason := g.typeReason(v.Type()); reason != "" {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
if !g.holdsInterface(v.Type()) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch v.Kind() {
|
||||||
|
case reflect.Pointer, reflect.Map, reflect.Slice:
|
||||||
|
if v.IsNil() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
key := viewModelVisit{ptr: v.Pointer(), typ: v.Type()}
|
||||||
|
if g.visited[key] {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
g.visited[key] = true
|
||||||
|
}
|
||||||
|
switch v.Kind() {
|
||||||
|
case reflect.Pointer:
|
||||||
|
return g.value(v.Elem())
|
||||||
|
case reflect.Struct:
|
||||||
|
for i := 0; i < v.NumField(); i++ {
|
||||||
|
if reason := g.value(v.Field(i)); reason != "" {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
for i := 0; i < v.Len(); i++ {
|
||||||
|
if reason := g.value(v.Index(i)); reason != "" {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case reflect.Map:
|
||||||
|
iter := v.MapRange()
|
||||||
|
for iter.Next() {
|
||||||
|
if reason := g.value(iter.Key()); reason != "" {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
if reason := g.value(iter.Value()); reason != "" {
|
||||||
|
return reason
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if carriesTrustedContent(reflect.TypeOf(vm), map[reflect.Type]bool{}) {
|
|
||||||
return "the view model carries pre-escaped html/template content"
|
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// typeReason is the refusal reason of a type and everything reachable from
|
||||||
|
// it, or "". A type in progress counts as accepted, which ends cycles; the
|
||||||
|
// type that closes the cycle is still checked on its own.
|
||||||
|
func (g *viewModelGuard) typeReason(t reflect.Type) string {
|
||||||
|
if t == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if reason, ok := g.types[t]; ok {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
g.types[t] = ""
|
||||||
|
reason := g.ownTypeReason(t)
|
||||||
|
g.types[t] = reason
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *viewModelGuard) ownTypeReason(t reflect.Type) string {
|
||||||
|
if trustedTemplateTypes[t] {
|
||||||
|
return "the view model carries pre-escaped html/template content"
|
||||||
|
}
|
||||||
|
if g.model != nil && t == g.model {
|
||||||
|
return "the view model carries the controller's model"
|
||||||
|
}
|
||||||
|
if gormModel(t) {
|
||||||
|
return "the view model carries a GORM model (" + t.String() + ")"
|
||||||
|
}
|
||||||
|
switch t.Kind() {
|
||||||
|
case reflect.Pointer, reflect.Slice, reflect.Array:
|
||||||
|
if reason := g.typeReason(t.Elem()); reason != "" {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
case reflect.Map:
|
||||||
|
if reason := g.typeReason(t.Key()); reason != "" {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
if reason := g.typeReason(t.Elem()); reason != "" {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
case reflect.Struct:
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
if reason := g.typeReason(t.Field(i).Type); reason != "" {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
methodSets := []reflect.Type{t}
|
||||||
|
if t.Kind() != reflect.Pointer && t.Kind() != reflect.Interface {
|
||||||
|
methodSets = append(methodSets, reflect.PointerTo(t))
|
||||||
|
}
|
||||||
|
for _, mt := range methodSets {
|
||||||
|
for i := 0; i < mt.NumMethod(); i++ {
|
||||||
|
method := mt.Method(i)
|
||||||
|
for o := 0; o < method.Type.NumOut(); o++ {
|
||||||
|
if reason := g.typeReason(method.Type.Out(o)); reason != "" {
|
||||||
|
return "method " + method.Name + " of " + t.String() + ": " + reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdsInterface reports whether a value of type t can hold an interface
|
||||||
|
// value, whose dynamic type only the value walk can check.
|
||||||
|
func (g *viewModelGuard) holdsInterface(t reflect.Type) bool {
|
||||||
|
if held, ok := g.holds[t]; ok {
|
||||||
|
return held
|
||||||
|
}
|
||||||
|
g.holds[t] = false
|
||||||
|
held := false
|
||||||
|
switch t.Kind() {
|
||||||
|
case reflect.Interface:
|
||||||
|
held = true
|
||||||
|
case reflect.Pointer, reflect.Slice, reflect.Array:
|
||||||
|
held = g.holdsInterface(t.Elem())
|
||||||
|
case reflect.Map:
|
||||||
|
held = g.holdsInterface(t.Key()) || g.holdsInterface(t.Elem())
|
||||||
|
case reflect.Struct:
|
||||||
|
for i := 0; i < t.NumField() && !held; i++ {
|
||||||
|
held = g.holdsInterface(t.Field(i).Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.holds[t] = held
|
||||||
|
return held
|
||||||
|
}
|
||||||
|
|
||||||
|
// gormModel reports whether t is a GORM model: a struct with a TableName
|
||||||
|
// method, a field with a gorm struct tag, or gorm.Model or gorm.DeletedAt
|
||||||
|
// itself (both also reached as embedded fields).
|
||||||
|
func gormModel(t reflect.Type) bool {
|
||||||
|
if t.Kind() != reflect.Struct {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if t == gormModelType || t == gormDeletedAtTyp || t.Implements(tablerType) || reflect.PointerTo(t).Implements(tablerType) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
if _, ok := t.Field(i).Tag.Lookup("gorm"); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// baseType strips pointers and the element types of slices, arrays and maps.
|
// baseType strips pointers and the element types of slices, arrays and maps.
|
||||||
func baseType(t reflect.Type) reflect.Type {
|
func baseType(t reflect.Type) reflect.Type {
|
||||||
for t != nil {
|
for t != nil {
|
||||||
@@ -292,29 +491,6 @@ func baseType(t reflect.Type) reflect.Type {
|
|||||||
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,
|
// 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
|
// 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
|
// a form partial on the create form). With ?id= the name must belong to a
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
"git.golem15.com/golem15/summercms/modules/towel"
|
"git.golem15.com/golem15/summercms/modules/towel"
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
"golang.org/x/net/html/atom"
|
"golang.org/x/net/html/atom"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sanitizeHTML runs raw markup through the same fragment parse and allowlist
|
// sanitizeHTML runs raw markup through the same fragment parse and allowlist
|
||||||
@@ -237,16 +239,49 @@ func TestPhase101PartialSanitizer(t *testing.T) {
|
|||||||
Items []struct{ A template.HTMLAttr }
|
Items []struct{ A template.HTMLAttr }
|
||||||
}{},
|
}{},
|
||||||
"template.URL": map[string]template.URL{},
|
"template.URL": map[string]template.URL{},
|
||||||
|
// WR-03: the model nested in a wrapper, behind an interface, as
|
||||||
|
// another GORM model, or reached through a method.
|
||||||
|
"wrapped model": struct{ Gadget *extGadget }{},
|
||||||
|
"wrapped model slice": struct{ Rows []struct{ G extGadget } }{},
|
||||||
|
"model in map[string]any": map[string]any{"gadget": &extGadget{Name: "top-secret"}},
|
||||||
|
"model in []any": []any{1, extGadget{}},
|
||||||
|
"model in an any field": struct{ Row any }{Row: []*extGadget{{}}},
|
||||||
|
"HTML in map[string]any": map[string]any{"banner": template.HTML("<b>x</b>")},
|
||||||
|
"another tabler model": struct{ User *BackendUser }{},
|
||||||
|
"gorm-tagged struct": struct{ Row vmTagged }{},
|
||||||
|
"embedded gorm.Model": struct{ vmGormModel }{},
|
||||||
|
"gorm.DeletedAt": struct{ Deleted gorm.DeletedAt }{},
|
||||||
|
"method returns HTML": vmHTMLMethod{},
|
||||||
|
"pointer method HTML": struct{ Inner vmPtrHTMLMethod }{},
|
||||||
|
"method with an argument": vmArgHTMLMethod{},
|
||||||
|
"method returns the model": vmModelMethod{},
|
||||||
} {
|
} {
|
||||||
if refusedViewModel(cc, vm) == "" {
|
if refusedViewModel(cc, vm) == "" {
|
||||||
t.Fatalf("%s view model was accepted", name)
|
t.Fatalf("%s view model was accepted", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
selfRef := &vmNode{Label: "a"}
|
||||||
|
selfRef.Next = selfRef
|
||||||
|
loop := map[string]any{"n": 1}
|
||||||
|
loop["self"] = loop
|
||||||
for name, vm := range map[string]any{
|
for name, vm := range map[string]any{
|
||||||
"nil": nil,
|
"nil": nil,
|
||||||
"curated": struct{ Name string }{},
|
"curated": struct{ Name string }{},
|
||||||
"items": struct{ Items []extStatItem }{},
|
"items": struct{ Items []extStatItem }{},
|
||||||
"string": "text",
|
"string": "text",
|
||||||
|
// The shape of a curated statistics view model: labels and
|
||||||
|
// integers, including behind interfaces.
|
||||||
|
"stats view": struct {
|
||||||
|
Total int
|
||||||
|
Formats []extStatItem
|
||||||
|
NoShelf int
|
||||||
|
}{Total: 3, Formats: []extStatItem{{Label: "x", Count: 3}}},
|
||||||
|
"curated map": map[string]any{"total": 3, "items": []extStatItem{{}}, "label": "x", "none": nil},
|
||||||
|
"time field": struct{ At time.Time }{At: time.Now()},
|
||||||
|
"self reference": selfRef,
|
||||||
|
"cyclic map": loop,
|
||||||
|
"plain method": vmPlainMethod{},
|
||||||
|
"nil pointer field": struct{ Next *vmNode }{},
|
||||||
} {
|
} {
|
||||||
if reason := refusedViewModel(cc, vm); reason != "" {
|
if reason := refusedViewModel(cc, vm); reason != "" {
|
||||||
t.Fatalf("%s view model refused: %s", name, reason)
|
t.Fatalf("%s view model refused: %s", name, reason)
|
||||||
@@ -282,3 +317,35 @@ type leakyController struct{ extController }
|
|||||||
func (leakyController) PartialData(context.Context, string, any) (any, error) {
|
func (leakyController) PartialData(context.Context, string, any) (any, error) {
|
||||||
return &extGadget{Name: "top-secret"}, nil
|
return &extGadget{Name: "top-secret"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// View-model fixtures of the guard (WR-03).
|
||||||
|
type vmTagged struct {
|
||||||
|
Secret string `gorm:"column:secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type vmGormModel struct{ gorm.Model }
|
||||||
|
|
||||||
|
type vmHTMLMethod struct{ Body string }
|
||||||
|
|
||||||
|
func (v vmHTMLMethod) Banner() template.HTML { return template.HTML(v.Body) }
|
||||||
|
|
||||||
|
type vmPtrHTMLMethod struct{ Body string }
|
||||||
|
|
||||||
|
func (v *vmPtrHTMLMethod) Banner() template.HTML { return template.HTML(v.Body) }
|
||||||
|
|
||||||
|
type vmArgHTMLMethod struct{}
|
||||||
|
|
||||||
|
func (vmArgHTMLMethod) Wrap(s string) template.HTML { return template.HTML(s) }
|
||||||
|
|
||||||
|
type vmModelMethod struct{}
|
||||||
|
|
||||||
|
func (vmModelMethod) Gadget() *extGadget { return &extGadget{} }
|
||||||
|
|
||||||
|
type vmPlainMethod struct{ N int }
|
||||||
|
|
||||||
|
func (v vmPlainMethod) Double() int { return v.N * 2 }
|
||||||
|
|
||||||
|
type vmNode struct {
|
||||||
|
Label string
|
||||||
|
Next *vmNode
|
||||||
|
}
|
||||||
|
|||||||
@@ -187,8 +187,9 @@ type HasAdminActions interface {
|
|||||||
// renders (config_list.yaml headerPartial, fields.yaml `type: partial`). name
|
// renders (config_list.yaml headerPartial, fields.yaml `type: partial`). name
|
||||||
// is the partial name; record is the scoped record for a form partial on an
|
// is the partial name; record is the scoped record for a form partial on an
|
||||||
// existing record, else nil. The result must be a curated view model built
|
// existing record, else nil. The result must be a curated view model built
|
||||||
// for the template, never the GORM model itself: the framework refuses a
|
// for the template, never a GORM model: the framework refuses a view model
|
||||||
// value of the controller's model type.
|
// that carries the controller's model or any other GORM model, even nested
|
||||||
|
// in a field, a collection or an interface value.
|
||||||
type AdminPartialData interface {
|
type AdminPartialData interface {
|
||||||
PartialData(ctx context.Context, name string, record any) (any, error)
|
PartialData(ctx context.Context, name string, record any) (any, error)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user