feat(10.1-01): serve controller JS/CSS and run registered toolbar actions

- boardwalk exports ContentType and SetSecurityHeaders
- pact.AdminClientAssets files are read and hashed at boot and served by exact
  key under {prefix}/assets/{vendor}/{plugin}/ with nosniff, CSP, CORP,
  no-cache and an ETag; a miss falls through to the SPA
- list and form schemas carry assets URLs with a ?v= hash
- toolbar.buttons resolves create, delete and registered actions after decode;
  toolbarActions is permission-filtered per admin
- POST .../toolbar/{action} behind requireAjax and action permissions
This commit is contained in:
Jakub Zych
2026-09-28 23:41:17 +02:00
parent f9281949a6
commit 8b1cb244de
23 changed files with 772 additions and 64 deletions

View File

@@ -61,6 +61,50 @@ func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) {
})
}
// toolbarAction serves POST .../{controller}/toolbar/{action} (D-12): a
// registered action the list's toolbar.buttons declares. A toolbar action
// carries no record id and no values, so it can never become an unscoped
// record lookup; its answer's fill is always empty.
func (s *service) toolbarAction(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
action, ok := toolbarActionOf(cc, r.PathValue("action"))
if !ok {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
if !s.allowAction(w, r, action) {
return
}
in, err := decodeActionRequest(r)
if err != nil {
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."}}})
return
}
s.runAction(w, r, cc, action, pact.AdminActionInput{}, nil)
})
}
// toolbarActionOf returns the registered action a list toolbar declares under
// name; the built-in create and delete are not actions.
func toolbarActionOf(cc *CompiledController, name string) (pact.AdminAction, bool) {
if cc == nil || cc.List == nil || builtinToolbarActions[name] {
return pact.AdminAction{}, false
}
declared := false
for _, button := range cc.List.ToolbarButtons {
declared = declared || button == name
}
if !declared {
return pact.AdminAction{}, false
}
action, ok := cc.Actions[name]
return action, ok
}
// allowAction applies the action's own permissions on top of the controller's
// (already checked by protect). A denial is logged and answered 403.
func (s *service) allowAction(w http.ResponseWriter, r *http.Request, action pact.AdminAction) bool {