Files
summercms/modules/cabana/actions.go
Jakub Zych 8b1cb244de 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
2026-09-28 23:41:17 +02:00

244 lines
7.5 KiB
Go

package cabana
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"reflect"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 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.
func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
field, ok := widgetField(cc, r.PathValue("field"))
if !ok {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
action, ok := cc.Actions[field.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
}
input := pact.AdminActionInput{Field: field.Name, Values: onlyFillScalars(field.Fill, in.Values)}
if in.RecordID != nil {
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
record, err := readScopedRecord(r.Context(), db, cc, *in.RecordID)
if err != nil {
writeCRUDError(w, err)
return
}
id := *in.RecordID
input.RecordID = &id
input.Record = record
}
s.runAction(w, r, cc, action, input, field.Fill)
})
}
// 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 {
principal, _ := bouncer.User(r.Context())
if Allows(principal, action.Permissions) {
return true
}
var adminID uint
if principal != nil {
adminID = principal.ID
}
s.logAuth(r, "denied", adminID)
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return false
}
// runAction calls the plugin's Run and writes the D-10 envelope. A
// *ValidationError is a 422; any other error is logged and answered with the
// generic 500 body, never the error text.
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))
result, err := action.Run(ctx, input)
if err != nil {
var invalid *ValidationError
if errors.As(err, &invalid) {
writeCRUDError(w, err)
return
}
slog.Error("cabana: admin action failed", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
WriteData(w, http.StatusOK, AdminActionResult{
Message: translateKey(ctx, tr, result.Message),
Fill: onlyFillScalars(fill, result.Fill),
}, nil)
}
// widgetField returns the form's `type: widget` field with the given name.
func widgetField(cc *CompiledController, name string) (FormField, bool) {
if cc == nil || cc.Form == nil || name == "" {
return FormField{}, false
}
for _, field := range cc.Form.Fields {
if field.Name == name && field.Type == "widget" {
return field, true
}
}
return FormField{}, false
}
// decodeActionRequest decodes the strict {record_id, values} body: unknown
// keys, a malformed body or trailing tokens are a validation failure (422).
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)
dec.UseNumber()
dec.DisallowUnknownFields()
var in AdminActionRequest
if err := dec.Decode(&in); err != nil {
return AdminActionRequest{}, invalid
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
return AdminActionRequest{}, invalid
}
return in, nil
}
// readScopedRecord loads one record through the controller's FormExtendQuery
// scope, exactly as show and update do, but without a row lock: it is a read
// outside any write transaction. Missing and out-of-scope ids are both
// recordNotFound (404).
func readScopedRecord(ctx context.Context, db *gorm.DB, cc *CompiledController, id uint64) (any, error) {
model, err := newWritableModel(cc)
if err != nil {
return nil, err
}
pk, err := coercePK(model, id)
if err != nil {
return nil, recordNotFound{}
}
q := db.WithContext(ctx)
if ext, ok := cc.Controller.(pact.FormExtendQuery); ok && ext != nil {
if next := ext.FormExtendQuery(ctx, q); next != nil {
q = next
}
}
err = q.Where(clause.Eq{Column: clause.Column{Name: primaryColumn(model)}, Value: pk}).Take(model).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, recordNotFound{}
}
if err != nil {
return nil, lifecycleFailure(cc, err)
}
return model, nil
}
// onlyFillScalars keeps the keys named in fill whose values are JSON scalars
// or null. The result is never nil.
func onlyFillScalars(fill []string, values map[string]any) map[string]any {
out := map[string]any{}
for _, key := range fill {
value, ok := values[key]
if !ok || !isJSONScalar(value) {
continue
}
out[key] = value
}
return out
}
// isJSONScalar reports whether v encodes as a JSON string, number, boolean or
// null.
func isJSONScalar(v any) bool {
if v == nil || nestedValue(v) {
return v == nil
}
rv := reflect.ValueOf(v)
for rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return true
}
rv = rv.Elem()
}
switch rv.Kind() {
case reflect.Bool, reflect.String,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
return true
default:
return false
}
}