feat(cabana): widget action payload and data channel (quick-261006-eyj)

- pact.AdminActionInput.Payload (json.RawMessage) carries the widget's own
  JSON value untouched; pact.AdminActionResult.Data is passed through as data
- cabana decodes payload with a 64 KiB cap (422 on body), refuses it on the
  toolbar and record routes, and embeds Data once encoded with a 256 KiB cap
  (opaque 500 when larger or unencodable); fill stays filtered
- root .swaggo overrides json.RawMessage so swag keeps record_id and values;
  admin.json and schema.d.ts regenerated (payload?: unknown, data?: unknown)
- TestWidgetPayloadAndData covers pass-through, cap, refusal and data 500
- cabana and pact READMEs, partials-and-widgets and admin-spa docs updated
This commit is contained in:
Jakub Zych
2026-10-06 11:13:16 +02:00
parent 964145628a
commit 6af88f9df6
12 changed files with 257 additions and 32 deletions

View File

@@ -15,11 +15,21 @@ import (
"gorm.io/gorm/clause"
)
// maxActionPayloadBytes caps the widget's own payload (the body key payload):
// a larger value is a 422 on body and the action never runs.
const maxActionPayloadBytes = 64 << 10
// maxActionDataBytes caps an action's encoded Data: a larger or unencodable
// value is logged and answered with the opaque 500 body.
const maxActionDataBytes = 256 << 10
// widgetAction serves POST .../{controller}/widgets/{field} (D-05, D-07): the
// SPA posts on behalf of a `type: widget` field, cabana checks the controller
// and action permissions, loads record_id through the controller's form scope
// and runs the registered action. Only the field's declared fill keys with
// scalar values reach the action and the response.
// scalar values reach the action and the response; the optional payload
// passes through to the action exactly as sent, never inspected and never
// used to select the record.
func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
field, ok := widgetField(cc, r.PathValue("field"))
@@ -52,6 +62,7 @@ func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) {
return
}
input := pact.AdminActionInput{Field: field.Name, Values: onlyFillScalars(field.Fill, in.Values)}
input.Payload = in.Payload
if in.RecordID != nil {
db, err := s.db()
if err != nil {
@@ -90,8 +101,8 @@ func (s *service) toolbarAction(w http.ResponseWriter, r *http.Request) {
writeCRUDError(w, err)
return
}
if in.RecordID != nil || in.Values != nil {
writeCRUDError(w, &ValidationError{Details: map[string]any{"body": []string{"A toolbar action takes no record_id or values."}}})
if in.RecordID != nil || in.Values != nil || len(in.Payload) > 0 {
writeCRUDError(w, &ValidationError{Details: map[string]any{"body": []string{"A toolbar action takes no record_id, values or payload."}}})
return
}
s.runAction(w, r, cc, action, pact.AdminActionInput{}, nil)
@@ -197,8 +208,8 @@ func (s *service) recordAction(w http.ResponseWriter, r *http.Request) {
writeCRUDError(w, err)
return
}
if in.RecordID != nil || in.Values != nil {
writeCRUDError(w, &ValidationError{Details: map[string]any{"body": []string{"A record action takes no record_id or values."}}})
if in.RecordID != nil || in.Values != nil || len(in.Payload) > 0 {
writeCRUDError(w, &ValidationError{Details: map[string]any{"body": []string{"A record action takes no record_id, values or payload."}}})
return
}
svc, err := s.crud()
@@ -256,7 +267,10 @@ func (s *service) allowAction(w http.ResponseWriter, r *http.Request, permission
// runAction calls the plugin's Run and writes the D-10 envelope. A
// *ValidationError is a 422 and a *ForbiddenError a 403; any other error is
// logged and answered with the generic 500 body, never the error text.
// logged and answered with the generic 500 body, never the error text. The
// result's Fill passes the fill filter; its Data bypasses that filter but not
// the size cap: it is encoded once and embedded as-is, and a value above
// maxActionDataBytes or one that cannot be encoded is an opaque 500.
func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *CompiledController, action pact.AdminAction, input pact.AdminActionInput, fill []string) {
tr := s.translator()
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), tr))
@@ -277,10 +291,20 @@ func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *Compiled
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
WriteData(w, http.StatusOK, AdminActionResult{
out := AdminActionResult{
Message: translateKey(ctx, tr, result.Message),
Fill: onlyFillScalars(fill, result.Fill),
}, nil)
}
if result.Data != nil {
raw, err := json.Marshal(result.Data)
if err != nil || len(raw) > maxActionDataBytes {
slog.Error("cabana: admin action data rejected", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "bytes", len(raw), "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
out.Data = json.RawMessage(raw)
}
WriteData(w, http.StatusOK, out, nil)
}
// widgetField returns the form's `type: widget` field with the given name.
@@ -296,8 +320,10 @@ func widgetField(cc *CompiledController, name string) (FormField, bool) {
return FormField{}, false
}
// decodeActionRequest decodes the strict {record_id, values} body: unknown
// keys, a malformed body or trailing tokens are a validation failure (422).
// decodeActionRequest decodes the strict {record_id, values, payload} body:
// unknown keys, a malformed body or trailing tokens are a validation failure
// (422), and so is a payload above maxActionPayloadBytes. The payload bytes
// are kept exactly as sent.
func decodeActionRequest(r *http.Request) (AdminActionRequest, error) {
invalid := &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}}
dec := json.NewDecoder(r.Body)
@@ -311,6 +337,9 @@ func decodeActionRequest(r *http.Request) (AdminActionRequest, error) {
if err := dec.Decode(&trailing); err != io.EOF {
return AdminActionRequest{}, invalid
}
if len(in.Payload) > maxActionPayloadBytes {
return AdminActionRequest{}, &ValidationError{Details: map[string]any{"body": []string{"The payload may not exceed 64 KiB."}}}
}
return in, nil
}