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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user